diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 07:08:36 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 07:08:36 +0000 |
commit | 48aff82709769b098321c738f3444b9bdaa694c6 (patch) | |
tree | e00c7c43e2d9b603a5a6af576b1685e400410dee /app/assets/javascripts | |
parent | 879f5329ee916a948223f8f43d77fba4da6cd028 (diff) | |
download | gitlab-ce-48aff82709769b098321c738f3444b9bdaa694c6.tar.gz |
Add latest changes from gitlab-org/gitlab@13-5-stable-eev13.5.0-rc42
Diffstat (limited to 'app/assets/javascripts')
867 files changed, 20974 insertions, 8422 deletions
diff --git a/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue b/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue index 2ea55d44420..bc2d96832fa 100644 --- a/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue +++ b/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue @@ -9,13 +9,13 @@ export default { }, inject: { svgPath: { - type: String, + default: '', }, docsLink: { - type: String, + default: '', }, primaryButtonPath: { - type: String, + default: '', }, }, }; diff --git a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue b/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue index 5429ec403d3..316827e1b07 100644 --- a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue +++ b/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue @@ -10,16 +10,16 @@ export default { }, inject: { isAdmin: { - type: Boolean, + default: false, }, svgPath: { - type: String, + default: '', }, docsLink: { - type: String, + default: '', }, primaryButtonPath: { - type: String, + default: '', }, }, }; diff --git a/app/assets/javascripts/alert_handler.js b/app/assets/javascripts/alert_handler.js index 8fffb61d1dd..26b0142f6a2 100644 --- a/app/assets/javascripts/alert_handler.js +++ b/app/assets/javascripts/alert_handler.js @@ -1,13 +1,21 @@ -// This allows us to dismiss alerts that we've migrated from bootstrap -// Note: This ONLY works on alerts that are created on page load +// This allows us to dismiss alerts and banners that we've migrated from bootstrap +// Note: This ONLY works on elements that are created on page load // You can follow this effort in the following epic // https://gitlab.com/groups/gitlab-org/-/epics/4070 export default function initAlertHandler() { - const ALERT_SELECTOR = '.gl-alert'; - const CLOSE_SELECTOR = '.gl-alert-dismiss'; + const DISMISSIBLE_SELECTORS = ['.gl-alert', '.gl-banner']; + const DISMISS_LABEL = '[aria-label="Dismiss"]'; + const DISMISS_CLASS = '.gl-alert-dismiss'; - const dismissAlert = ({ target }) => target.closest(ALERT_SELECTOR).remove(); - const closeButtons = document.querySelectorAll(`${ALERT_SELECTOR} ${CLOSE_SELECTOR}`); - closeButtons.forEach(alert => alert.addEventListener('click', dismissAlert)); + DISMISSIBLE_SELECTORS.forEach(selector => { + const elements = document.querySelectorAll(selector); + elements.forEach(element => { + const button = element.querySelector(DISMISS_LABEL) || element.querySelector(DISMISS_CLASS); + if (!button) { + return; + } + button.addEventListener('click', () => element.remove()); + }); + }); } diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue index c6605452616..f7a5d31b835 100644 --- a/app/assets/javascripts/alert_management/components/alert_details.vue +++ b/app/assets/javascripts/alert_management/components/alert_details.vue @@ -1,16 +1,17 @@ <script> -/* eslint-disable vue/no-v-html */ -import * as Sentry from '@sentry/browser'; import { GlAlert, GlBadge, GlIcon, + GlLink, GlLoadingIcon, GlSprintf, GlTabs, GlTab, GlButton, + GlSafeHtmlDirective, } from '@gitlab/ui'; +import * as Sentry from '~/sentry/wrapper'; import { s__ } from '~/locale'; import alertQuery from '../graphql/queries/details.query.graphql'; import sidebarStatusQuery from '../graphql/queries/sidebar_status.query.graphql'; @@ -28,6 +29,8 @@ import SystemNote from './system_notes/system_note.vue'; import AlertSidebar from './alert_sidebar.vue'; import AlertMetrics from './alert_metrics.vue'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import AlertSummaryRow from './alert_summary_row.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const containerEl = document.querySelector('.page-with-contextual-sidebar'); @@ -39,6 +42,9 @@ export default { reportedAt: s__('AlertManagement|Reported %{when}'), reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'), }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, severityLabels: ALERTS_SEVERITY_LABELS, tabsConfig: [ { @@ -56,9 +62,11 @@ export default { ], components: { AlertDetailsTable, + AlertSummaryRow, GlBadge, GlAlert, GlIcon, + GlLink, GlLoadingIcon, GlSprintf, GlTab, @@ -69,20 +77,18 @@ export default { SystemNote, AlertMetrics, }, + mixins: [glFeatureFlagsMixin()], inject: { projectPath: { default: '', }, alertId: { - type: String, default: '', }, projectId: { - type: String, default: '', }, projectIssuesPath: { - type: String, default: '', }, }, @@ -143,6 +149,15 @@ export default { this.$router.replace({ name: 'tab', params: { tabId } }); }, }, + environmentName() { + return this.shouldDisplayEnvironment && this.alert?.environment?.name; + }, + environmentPath() { + return this.shouldDisplayEnvironment && this.alert?.environment?.path; + }, + shouldDisplayEnvironment() { + return this.glFeatures.exposeEnvironmentPathInAlertDetails; + }, }, mounted() { this.trackPageViews(); @@ -211,7 +226,7 @@ export default { <template> <div> <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError"> - <p v-html="sidebarErrorMessage || $options.i18n.errorMsg"></p> + <p v-safe-html="sidebarErrorMessage || $options.i18n.errorMsg"></p> </gl-alert> <gl-alert v-if="createIncidentError" @@ -270,10 +285,9 @@ export default { variant="default" class="d-sm-none gl-absolute toggle-sidebar-mobile-button" type="button" + icon="chevron-double-lg-left" @click="toggleSidebar" - > - <i class="fa fa-angle-double-left"></i> - </gl-button> + /> </div> <div v-if="alert" @@ -283,54 +297,65 @@ export default { </div> <gl-tabs v-if="alert" v-model="currentTabIndex" data-testid="alertDetailsTabs"> <gl-tab :data-testid="$options.tabsConfig[0].id" :title="$options.tabsConfig[0].title"> - <div v-if="alert.severity" class="gl-mt-3 gl-mb-5 gl-display-flex"> - <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Severity') }}: - </div> - <div class="gl-pl-2" data-testid="severity"> - <span> - <gl-icon - class="gl-vertical-align-middle" - :size="12" - :name="`severity-${alert.severity.toLowerCase()}`" - :class="`icon-${alert.severity.toLowerCase()}`" - /> - </span> + <alert-summary-row v-if="alert.severity" :label="`${s__('AlertManagement|Severity')}:`"> + <span data-testid="severity"> + <gl-icon + class="gl-vertical-align-middle" + :size="12" + :name="`severity-${alert.severity.toLowerCase()}`" + :class="`icon-${alert.severity.toLowerCase()}`" + /> {{ $options.severityLabels[alert.severity] }} - </div> - </div> - <div v-if="alert.startedAt" class="gl-my-5 gl-display-flex"> - <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Start time') }}: - </div> - <div class="gl-pl-2"> - <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" /> - </div> - </div> - <div v-if="alert.eventCount" class="gl-my-5 gl-display-flex"> - <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Events') }}: - </div> - <div class="gl-pl-2" data-testid="eventCount">{{ alert.eventCount }}</div> - </div> - <div v-if="alert.monitoringTool" class="gl-my-5 gl-display-flex"> - <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Tool') }}: - </div> - <div class="gl-pl-2" data-testid="monitoringTool">{{ alert.monitoringTool }}</div> - </div> - <div v-if="alert.service" class="gl-my-5 gl-display-flex"> - <div class="bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Service') }}: - </div> - <div class="gl-pl-2" data-testid="service">{{ alert.service }}</div> - </div> - <div v-if="alert.runbook" class="gl-my-5 gl-display-flex"> - <div class="bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Runbook') }}: - </div> - <div class="gl-pl-2" data-testid="runbook">{{ alert.runbook }}</div> - </div> + </span> + </alert-summary-row> + <alert-summary-row + v-if="environmentName" + :label="`${s__('AlertManagement|Environment')}:`" + > + <gl-link + v-if="environmentPath" + class="gl-display-inline-block" + data-testid="environmentPath" + :href="environmentPath" + > + {{ environmentName }} + </gl-link> + <span v-else data-testid="environmentName">{{ environmentName }}</span> + </alert-summary-row> + <alert-summary-row + v-if="alert.startedAt" + :label="`${s__('AlertManagement|Start time')}:`" + > + <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" /> + </alert-summary-row> + <alert-summary-row + v-if="alert.eventCount" + :label="`${s__('AlertManagement|Events')}:`" + data-testid="eventCount" + > + {{ alert.eventCount }} + </alert-summary-row> + <alert-summary-row + v-if="alert.monitoringTool" + :label="`${s__('AlertManagement|Tool')}:`" + data-testid="monitoringTool" + > + {{ alert.monitoringTool }} + </alert-summary-row> + <alert-summary-row + v-if="alert.service" + :label="`${s__('AlertManagement|Service')}:`" + data-testid="service" + > + {{ alert.service }} + </alert-summary-row> + <alert-summary-row + v-if="alert.runbook" + :label="`${s__('AlertManagement|Runbook')}:`" + data-testid="runbook" + > + {{ alert.runbook }} + </alert-summary-row> <alert-details-table :alert="alert" :loading="loading" /> </gl-tab> <gl-tab :data-testid="$options.tabsConfig[1].id" :title="$options.tabsConfig[1].title"> diff --git a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue index 68443166f40..c5ff2dc0d11 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue @@ -33,30 +33,13 @@ export default { query: alertsHelpUrlQuery, }, }, - 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: '', - }, - }, + inject: [ + 'enableAlertManagementPath', + 'userCanEnableAlertManagement', + 'emptyAlertSvgPath', + 'opsgenieMvcEnabled', + 'opsgenieMvcTargetUrl', + ], data() { return { alertsHelpUrl: '', 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 index 094f33fed3b..5e9cdfb3fed 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue @@ -1,6 +1,4 @@ <script> -import Tracking from '~/tracking'; -import { trackAlertListViewsOptions } from '../constants'; import AlertManagementEmptyState from './alert_management_empty_state.vue'; import AlertManagementTable from './alert_management_table.vue'; @@ -9,67 +7,12 @@ export default { 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); - }, - }, + inject: ['alertManagementEnabled'], }; </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" - /> + <alert-management-table v-if="alertManagementEnabled" /> + <alert-management-empty-state v-else /> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index 0fd00fe90eb..f287b425826 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -1,58 +1,43 @@ <script> -/* eslint-disable vue/no-v-html */ import { + GlAlert, GlLoadingIcon, GlTable, - GlAlert, GlAvatarsInline, GlAvatarLink, GlAvatar, GlIcon, GlLink, - GlTabs, - GlTab, - GlBadge, - GlPagination, - GlSearchBoxByType, GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; -import { debounce, trim } from 'lodash'; -import { __, s__ } from '~/locale'; -import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; +import { s__, __ } from '~/locale'; import { fetchPolicies } from '~/lib/graphql'; +import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; +import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue'; +import { + tdClass, + thClass, + bodyTrClass, + initialPaginationState, +} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { convertToSnakeCase } from '~/lib/utils/text_utility'; -import Tracking from '~/tracking'; import getAlerts from '../graphql/queries/get_alerts.query.graphql'; import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; import { ALERTS_STATUS_TABS, ALERTS_SEVERITY_LABELS, - DEFAULT_PAGE_SIZE, trackAlertListViewsOptions, - trackAlertStatusUpdateOptions, } from '../constants'; import AlertStatus from './alert_status.vue'; -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'; const TH_TEST_ID = { 'data-testid': 'alert-management-severity-sort' }; -const initialPaginationState = { - currentPage: 1, - prevPageCursor: '', - nextPageCursor: '', - firstPageSize: DEFAULT_PAGE_SIZE, - lastPageSize: null, -}; - const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000; export default { + trackAlertListViewsOptions, i18n: { noAlertsMsg: s__( 'AlertManagement|No alerts available to display. See %{linkStart}enabling alert management%{linkEnd} for more information on adding alerts to the list.', @@ -60,7 +45,6 @@ export default { 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...'), unassigned: __('Unassigned'), }, fields: [ @@ -94,7 +78,7 @@ export default { }, { key: 'issue', - label: s__('AlertManagement|Issue'), + label: s__('AlertManagement|Incident'), thClass: 'gl-w-12 gl-pointer-events-none', tdClass, }, @@ -115,36 +99,23 @@ export default { severityLabels: ALERTS_SEVERITY_LABELS, statusTabs: ALERTS_STATUS_TABS, components: { + GlAlert, GlLoadingIcon, GlTable, - GlAlert, GlAvatarsInline, GlAvatarLink, GlAvatar, TimeAgo, GlIcon, GlLink, - GlTabs, - GlTab, - GlBadge, - GlPagination, - GlSearchBoxByType, GlSprintf, AlertStatus, + PaginatedTableWithSearchAndTabs, }, directives: { GlTooltip: GlTooltipDirective, }, - props: { - projectPath: { - type: String, - required: true, - }, - populatingAlertsHelpUrl: { - type: String, - required: true, - }, - }, + inject: ['projectPath', 'textQuery', 'assigneeUsernameQuery', 'populatingAlertsHelpUrl'], apollo: { alerts: { fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, @@ -152,6 +123,7 @@ export default { variables() { return { searchTerm: this.searchTerm, + assigneeUsername: this.assigneeUsername, projectPath: this.projectPath, statuses: this.statusFilter, sort: this.sort, @@ -182,14 +154,16 @@ export default { }; }, error() { - this.hasError = true; + this.errored = true; }, }, alertsCount: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, query: getAlertsCountByStatus, variables() { return { searchTerm: this.searchTerm, + assigneeUsername: this.assigneeUsername, projectPath: this.projectPath, }; }, @@ -200,83 +174,53 @@ export default { }, data() { return { - searchTerm: '', - hasError: false, - errorMessage: '', - isAlertDismissed: false, + errored: false, + serverErrorMessage: '', + isErrorAlertDismissed: false, sort: 'STARTED_AT_DESC', statusFilter: [], filteredByStatus: '', - pagination: initialPaginationState, + alerts: {}, + alertsCount: {}, sortBy: 'startedAt', sortDesc: true, sortDirection: 'desc', + searchTerm: this.textQuery, + assigneeUsername: this.assigneeUsernameQuery, + pagination: initialPaginationState, }; }, computed: { + showErrorMsg() { + return this.errored && !this.isErrorAlertDismissed; + }, showNoAlertsMsg() { return ( - !this.hasError && + !this.errored && !this.loading && this.alertsCount?.all === 0 && !this.searchTerm && - !this.isAlertDismissed + !this.assigneeUsername && + !this.isErrorAlertDismissed ); }, loading() { return this.$apollo.queries.alerts.loading; }, - hasAlerts() { - return this.alerts?.list?.length; - }, - showPaginationControls() { - return Boolean(this.prevPage || this.nextPage); - }, - alertsForCurrentTab() { - return this.alertsCount ? this.alertsCount[this.filteredByStatus.toLowerCase()] : 0; - }, - prevPage() { - return Math.max(this.pagination.currentPage - 1, 0); - }, - nextPage() { - const nextPage = this.pagination.currentPage + 1; - return nextPage > Math.ceil(this.alertsForCurrentTab / DEFAULT_PAGE_SIZE) ? null : nextPage; + isEmpty() { + return !this.alerts?.list?.length; }, }, - mounted() { - this.trackPageViews(); - }, methods: { - filterAlertsByStatus(tabIndex) { - this.resetPagination(); - const { filters, status } = this.$options.statusTabs[tabIndex]; - this.statusFilter = filters; - this.filteredByStatus = status; - }, fetchSortedData({ sortBy, sortDesc }) { const sortingDirection = sortDesc ? 'DESC' : 'ASC'; const sortingColumn = convertToSnakeCase(sortBy).toUpperCase(); - this.resetPagination(); + this.pagination = initialPaginationState; this.sort = `${sortingColumn}_${sortingDirection}`; }, - 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')); - }, - trackPageViews() { - const { category, action } = trackAlertListViewsOptions; - Tracking.event(category, action); - }, - trackStatusUpdate(status) { - const { category, action, label } = trackAlertStatusUpdateOptions; - Tracking.event(category, action, { label, property: status }); + navigateToAlertDetails({ iid }, index, { metaKey }) { + return visitUrl(joinPaths(window.location.pathname, iid, 'details'), metaKey); }, hasAssignees(assignees) { return Boolean(assignees.nodes?.length); @@ -284,204 +228,180 @@ export default { getIssueLink(item) { return joinPaths('/', this.projectPath, '-', 'issues', item.issueIid); }, - handlePageChange(page) { - const { startCursor, endCursor } = this.alerts.pageInfo; - - if (page > this.pagination.currentPage) { - this.pagination = { - ...initialPaginationState, - nextPageCursor: endCursor, - currentPage: page, - }; - } else { - this.pagination = { - lastPageSize: DEFAULT_PAGE_SIZE, - firstPageSize: null, - prevPageCursor: startCursor, - nextPageCursor: '', - currentPage: page, - }; - } - }, - resetPagination() { - this.pagination = initialPaginationState; - }, tbodyTrClass(item) { return { - [bodyTrClass]: !this.loading && this.hasAlerts, + [bodyTrClass]: !this.loading && !this.isEmpty, 'new-alert': item?.isNew, }; }, handleAlertError(errorMessage) { - this.hasError = true; - this.errorMessage = errorMessage; + this.errored = true; + this.serverErrorMessage = errorMessage; }, - dismissError() { - this.hasError = false; - this.errorMessage = ''; + handleStatusUpdate() { + this.$apollo.queries.alerts.refetch(); + this.$apollo.queries.alertsCount.refetch(); + }, + pageChanged(pagination) { + this.pagination = pagination; + }, + statusChanged({ filters, status }) { + this.statusFilter = filters; + this.filteredByStatus = status; + }, + filtersChanged({ searchTerm, assigneeUsername }) { + this.searchTerm = searchTerm; + this.assigneeUsername = assigneeUsername; + }, + errorAlertDismissed() { + this.errored = false; + this.serverErrorMessage = ''; + this.isErrorAlertDismissed = true; }, }, }; </script> <template> <div> - <div class="incident-management-list"> - <gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true"> - <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="hasError" variant="danger" data-testid="alert-error" @dismiss="dismissError"> - <p v-html="errorMessage || $options.i18n.errorMsg"></p> - </gl-alert> - - <gl-tabs - content-class="gl-p-0 gl-border-b-solid gl-border-b-1 gl-border-gray-100" - @input="filterAlertsByStatus" - > - <gl-tab v-for="tab in $options.statusTabs" :key="tab.status"> - <template slot="title"> - <span>{{ tab.title }}</span> - <gl-badge v-if="alertsCount" pill size="sm" class="gl-tab-counter-badge"> - {{ alertsCount[tab.status.toLowerCase()] }} - </gl-badge> - </template> - </gl-tab> - </gl-tabs> + <gl-alert v-if="showNoAlertsMsg" @dismiss="errorAlertDismissed"> + <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> - <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> + <paginated-table-with-search-and-tabs + :show-error-msg="showErrorMsg" + :i18n="$options.i18n" + :items="alerts.list || []" + :page-info="alerts.pageInfo" + :items-count="alertsCount" + :status-tabs="$options.statusTabs" + :track-views-options="$options.trackAlertListViewsOptions" + :server-error-message="serverErrorMessage" + :filter-search-tokens="['assignee_username']" + filter-search-key="alerts" + @page-changed="pageChanged" + @tabs-changed="statusChanged" + @filters-changed="filtersChanged" + @error-alert-dismissed="errorAlertDismissed" + > + <template #header-actions></template> - <h4 class="d-block d-md-none my-3"> + <template #title> {{ s__('AlertManagement|Alerts') }} - </h4> - <gl-table - class="alert-management-table" - :items="alerts ? alerts.list : []" - :fields="$options.fields" - :show-empty="true" - :busy="loading" - stacked="md" - :tbody-tr-class="tbodyTrClass" - :no-local-sorting="true" - :sort-direction="sortDirection" - :sort-desc.sync="sortDesc" - :sort-by.sync="sortBy" - sort-icon-left - fixed - @row-clicked="navigateToAlertDetails" - @sort-changed="fetchSortedData" - > - <template #cell(severity)="{ item }"> - <div - class="d-inline-flex align-items-center justify-content-between" - data-testid="severityField" - > - <gl-icon - class="mr-2" - :size="12" - :name="`severity-${item.severity.toLowerCase()}`" - :class="`icon-${item.severity.toLowerCase()}`" - /> - {{ $options.severityLabels[item.severity] }} - </div> - </template> + </template> - <template #cell(startedAt)="{ item }"> - <time-ago v-if="item.startedAt" :time="item.startedAt" /> - </template> + <template #table> + <gl-table + class="alert-management-table" + :items="alerts ? alerts.list : []" + :fields="$options.fields" + :show-empty="true" + :busy="loading" + stacked="md" + :tbody-tr-class="tbodyTrClass" + :no-local-sorting="true" + :sort-direction="sortDirection" + :sort-desc.sync="sortDesc" + :sort-by.sync="sortBy" + sort-icon-left + fixed + @row-clicked="navigateToAlertDetails" + @sort-changed="fetchSortedData" + > + <template #cell(severity)="{ item }"> + <div + class="d-inline-flex align-items-center justify-content-between" + data-testid="severityField" + > + <gl-icon + class="mr-2" + :size="12" + :name="`severity-${item.severity.toLowerCase()}`" + :class="`icon-${item.severity.toLowerCase()}`" + /> + {{ $options.severityLabels[item.severity] }} + </div> + </template> - <template #cell(eventCount)="{ item }"> - {{ item.eventCount }} - </template> + <template #cell(startedAt)="{ item }"> + <time-ago v-if="item.startedAt" :time="item.startedAt" /> + </template> - <template #cell(alertLabel)="{ item }"> - <div - class="gl-max-w-full text-truncate" - :title="`${item.iid} - ${item.title}`" - data-testid="idField" - > - #{{ item.iid }} {{ item.title }} - </div> - </template> + <template #cell(eventCount)="{ item }"> + {{ item.eventCount }} + </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(alertLabel)="{ item }"> + <div + class="gl-max-w-full text-truncate" + :title="`${item.iid} - ${item.title}`" + data-testid="idField" + > + #{{ item.iid }} {{ item.title }} + </div> + </template> - <template #cell(assignees)="{ item }"> - <div data-testid="assigneesField"> - <template v-if="hasAssignees(item.assignees)"> - <gl-avatars-inline - :avatars="item.assignees.nodes" - :collapsed="true" - :max-visible="4" - :avatar-size="24" - badge-tooltip-prop="name" - :badge-tooltip-max-chars="100" - > - <template #avatar="{ avatar }"> - <gl-avatar-link - :key="avatar.username" - v-gl-tooltip - target="_blank" - :href="avatar.webUrl" - :title="avatar.name" - > - <gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" /> - </gl-avatar-link> - </template> - </gl-avatars-inline> - </template> - <template v-else> - {{ $options.i18n.unassigned }} - </template> - </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(status)="{ item }"> - <alert-status - :alert="item" - :project-path="projectPath" - :is-sidebar="false" - @alert-error="handleAlertError" - /> - </template> + <template #cell(assignees)="{ item }"> + <div data-testid="assigneesField"> + <template v-if="hasAssignees(item.assignees)"> + <gl-avatars-inline + :avatars="item.assignees.nodes" + :collapsed="true" + :max-visible="4" + :avatar-size="24" + badge-tooltip-prop="name" + :badge-tooltip-max-chars="100" + > + <template #avatar="{ avatar }"> + <gl-avatar-link + :key="avatar.username" + v-gl-tooltip + target="_blank" + :href="avatar.webUrl" + :title="avatar.name" + > + <gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" /> + </gl-avatar-link> + </template> + </gl-avatars-inline> + </template> + <template v-else> + {{ $options.i18n.unassigned }} + </template> + </div> + </template> - <template #empty> - {{ s__('AlertManagement|No alerts to display.') }} - </template> + <template #cell(status)="{ item }"> + <alert-status + :alert="item" + :project-path="projectPath" + :is-sidebar="false" + @alert-error="handleAlertError" + @hide-dropdown="handleStatusUpdate" + /> + </template> - <template #table-busy> - <gl-loading-icon size="lg" color="dark" class="mt-3" /> - </template> - </gl-table> + <template #empty> + {{ s__('AlertManagement|No alerts to display.') }} + </template> - <gl-pagination - v-if="showPaginationControls" - :value="pagination.currentPage" - :prev-page="prevPage" - :next-page="nextPage" - align="center" - class="gl-pagination gl-mt-3" - @input="handlePageChange" - /> - </div> + <template #table-busy> + <gl-loading-icon size="lg" color="dark" class="mt-3" /> + </template> + </gl-table> + </template> + </paginated-table-with-search-and-tabs> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/alert_metrics.vue b/app/assets/javascripts/alert_management/components/alert_metrics.vue index c5b40edc672..8a6490ecd5c 100644 --- a/app/assets/javascripts/alert_management/components/alert_metrics.vue +++ b/app/assets/javascripts/alert_management/components/alert_metrics.vue @@ -1,7 +1,7 @@ <script> import Vue from 'vue'; import Vuex from 'vuex'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/wrapper'; Vue.use(Vuex); diff --git a/app/assets/javascripts/alert_management/components/alert_sidebar.vue b/app/assets/javascripts/alert_management/components/alert_sidebar.vue index 64e4089c85a..41d77716592 100644 --- a/app/assets/javascripts/alert_management/components/alert_sidebar.vue +++ b/app/assets/javascripts/alert_management/components/alert_sidebar.vue @@ -18,7 +18,6 @@ export default { default: '', }, projectId: { - type: String, default: '', }, }, diff --git a/app/assets/javascripts/alert_management/components/alert_status.vue b/app/assets/javascripts/alert_management/components/alert_status.vue index ff71b348cc9..3083a85cbd9 100644 --- a/app/assets/javascripts/alert_management/components/alert_status.vue +++ b/app/assets/javascripts/alert_management/components/alert_status.vue @@ -1,9 +1,9 @@ <script> -import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; import { trackAlertStatusUpdateOptions } from '../constants'; -import updateAlertStatus from '../graphql/mutations/update_alert_status.mutation.graphql'; +import updateAlertStatusMutation from '../graphql/mutations/update_alert_status.mutation.graphql'; export default { i18n: { @@ -18,9 +18,8 @@ export default { RESOLVED: s__('AlertManagement|Resolved'), }, components: { - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlButton, + GlDropdown, + GlDropdownItem, }, props: { projectPath: { @@ -51,7 +50,7 @@ export default { this.$emit('handle-updating', true); this.$apollo .mutate({ - mutation: updateAlertStatus, + mutation: updateAlertStatusMutation, variables: { iid: this.alert.iid, status: status.toUpperCase(), @@ -60,8 +59,6 @@ export default { }) .then(resp => { this.trackStatusUpdate(status); - this.$emit('hide-dropdown'); - const errors = resp.data?.updateAlertStatus?.errors || []; if (errors[0]) { @@ -70,6 +67,8 @@ export default { `${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${errors[0]}`, ); } + + this.$emit('hide-dropdown'); }) .catch(() => { this.$emit( @@ -91,39 +90,30 @@ export default { <template> <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> - <gl-deprecated-dropdown + <gl-dropdown ref="dropdown" right :text="$options.statuses[alert.status]" class="w-100" toggle-class="dropdown-menu-toggle" - variant="outline-default" @keydown.esc.native="$emit('hide-dropdown')" @hide="$emit('hide-dropdown')" > - <div v-if="isSidebar" class="dropdown-title gl-display-flex"> - <span class="alert-title gl-ml-auto">{{ s__('AlertManagement|Assign status') }}</span> - <gl-button - :aria-label="__('Close')" - variant="link" - class="dropdown-title-button dropdown-menu-close gl-ml-auto gl-text-black-normal!" - icon="close" - @click="$emit('hide-dropdown')" - /> - </div> + <p v-if="isSidebar" class="gl-new-dropdown-header-top" data-testid="dropdown-header"> + {{ s__('AlertManagement|Assign status') }} + </p> <div class="dropdown-content dropdown-body"> - <gl-deprecated-dropdown-item + <gl-dropdown-item v-for="(label, field) in $options.statuses" :key="field" data-testid="statusDropdownItem" - class="gl-vertical-align-middle" :active="label.toUpperCase() === alert.status" :active-class="'is-active'" @click="updateAlertStatus(label)" > {{ label }} - </gl-deprecated-dropdown-item> + </gl-dropdown-item> </div> - </gl-deprecated-dropdown> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/alert_summary_row.vue b/app/assets/javascripts/alert_management/components/alert_summary_row.vue new file mode 100644 index 00000000000..13835b7e2fa --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_summary_row.vue @@ -0,0 +1,18 @@ +<script> +export default { + props: { + label: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="gl-my-5 gl-display-flex"> + <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">{{ label }}</div> + <div class="gl-pl-2"> + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue index 0a1478ef5fe..df07038151e 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue @@ -1,9 +1,9 @@ <script> -import { GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; export default { components: { - GlDeprecatedDropdownItem, + GlDropdownItem, }, props: { user: { @@ -24,7 +24,7 @@ export default { </script> <template> - <gl-deprecated-dropdown-item + <gl-dropdown-item :key="user.username" data-testid="assigneeDropdownItem" class="assignee-dropdown-item gl-vertical-align-middle" @@ -47,5 +47,5 @@ export default { </strong> <span class="dropdown-menu-user-username"> {{ user.username }}</span> </span> - </gl-deprecated-dropdown-item> + </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue index 0f354e85e96..5e4fd56738b 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue @@ -1,10 +1,11 @@ <script> import { GlIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownHeader, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownDivider, + GlDropdownSectionHeader, + GlDropdownItem, + GlSearchBoxByType, GlLoadingIcon, GlTooltip, GlButton, @@ -33,10 +34,11 @@ export default { }, components: { GlIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownHeader, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlDropdownSectionHeader, + GlSearchBoxByType, GlLoadingIcon, GlTooltip, GlButton, @@ -216,48 +218,32 @@ export default { </p> <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> - <gl-deprecated-dropdown + <gl-dropdown ref="dropdown" :text="userName" class="w-100" toggle-class="dropdown-menu-toggle" - variant="outline-default" @keydown.esc.native="hideDropdown" @hide="hideDropdown" > - <div class="dropdown-title gl-display-flex"> - <span class="alert-title gl-ml-auto">{{ __('Assign To') }}</span> - <gl-button - :aria-label="__('Close')" - variant="link" - class="dropdown-title-button dropdown-menu-close gl-ml-auto gl-text-black-normal!" - icon="close" - @click="hideDropdown" - /> - </div> - <div class="dropdown-input"> - <input - v-model.trim="search" - class="dropdown-input-field" - type="search" - :placeholder="__('Search users')" - /> - <gl-icon name="search" class="dropdown-input-search ic-search" data-hidden="true" /> - </div> + <p class="gl-new-dropdown-header-top"> + {{ __('Assign To') }} + </p> + <gl-search-box-by-type v-model.trim="search" :placeholder="__('Search users')" /> <div class="dropdown-content dropdown-body"> <template v-if="userListValid"> - <gl-deprecated-dropdown-item + <gl-dropdown-item :active="!userName" active-class="is-active" @click="updateAlertAssignees('')" > {{ __('Unassigned') }} - </gl-deprecated-dropdown-item> - <gl-deprecated-dropdown-divider /> + </gl-dropdown-item> + <gl-dropdown-divider /> - <gl-deprecated-dropdown-header class="mt-0"> + <gl-dropdown-section-header> {{ __('Assignee') }} - </gl-deprecated-dropdown-header> + </gl-dropdown-section-header> <sidebar-assignee v-for="user in sortedUsers" :key="user.username" @@ -266,12 +252,12 @@ export default { @update-alert-assignees="updateAlertAssignees" /> </template> - <gl-deprecated-dropdown-item v-else-if="userListEmpty"> + <p v-else-if="userListEmpty" class="mx-3 my-2"> {{ __('No Matching Results') }} - </gl-deprecated-dropdown-item> + </p> <gl-loading-icon v-else /> </div> - </gl-deprecated-dropdown> + </gl-dropdown> </div> <gl-loading-icon v-if="isUpdating" :inline="true" /> diff --git a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue index 0b206ce42f4..3705e36a579 100644 --- a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue +++ b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue @@ -1,11 +1,12 @@ <script> /* eslint-disable vue/no-v-html */ +import { GlIcon } from '@gitlab/ui'; import NoteHeader from '~/notes/components/note_header.vue'; -import { spriteIcon } from '~/lib/utils/common_utils'; export default { components: { NoteHeader, + GlIcon, }, props: { note: { @@ -24,23 +25,23 @@ export default { } = this.note; return { ...author, id: id?.split('/').pop() }; }, - iconHtml() { - return spriteIcon(this.note?.systemNoteIconName); - }, }, }; </script> <template> - <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-px-0!"> - <div class="timeline-entry-inner"> - <div class="timeline-icon" v-html="iconHtml"></div> - <div class="timeline-content"> - <div class="note-header"> - <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id"> - <span v-html="note.bodyHtml"></span> - </note-header> - </div> + <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-p-0!"> + <div class="gl-display-inline-flex gl-align-items-center"> + <div + class="gl-display-inline gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-box-sizing-content-box gl-p-3 gl-mt-n2 gl-mr-6" + > + <gl-icon :name="note.systemNoteIconName" /> + </div> + + <div class="note-header"> + <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id"> + <span v-html="note.bodyHtml"></span> + </note-header> </div> </div> </li> diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js index 73cb5ecdf98..b79a64646eb 100644 --- a/app/assets/javascripts/alert_management/constants.js +++ b/app/assets/javascripts/alert_management/constants.js @@ -63,5 +63,3 @@ export const trackAlertStatusUpdateOptions = { action: 'update_alert_status', label: 'Status', }; - -export const DEFAULT_PAGE_SIZE = 20; diff --git a/app/assets/javascripts/alert_management/details.js b/app/assets/javascripts/alert_management/details.js index c2020dfcbe3..cbbdecae390 100644 --- a/app/assets/javascripts/alert_management/details.js +++ b/app/assets/javascripts/alert_management/details.js @@ -1,11 +1,11 @@ +import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import produce from 'immer'; -import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import createDefaultClient from '~/lib/graphql'; -import createRouter from './router'; import AlertDetails from './components/alert_details.vue'; import sidebarStatusQuery from './graphql/queries/sidebar_status.query.graphql'; +import createRouter from './router'; Vue.use(VueApollo); 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 0712ff12c23..406dfe97ce0 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 @@ -10,6 +10,11 @@ fragment AlertDetailItem on AlertManagementAlert { description updatedAt endedAt + hosts + environment { + name + path + } details runbook todos { 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 8ac00bbc6b5..bc7e51a2e90 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,7 +1,6 @@ #import "../fragments/list_item.fragment.graphql" query getAlerts( - $searchTerm: String $projectPath: ID! $statuses: [AlertManagementStatus!] $sort: AlertManagementAlertSort @@ -9,10 +8,13 @@ query getAlerts( $lastPageSize: Int $prevPageCursor: String = "" $nextPageCursor: String = "" + $searchTerm: String = "" + $assigneeUsername: String = "" ) { project(fullPath: $projectPath) { alertManagementAlerts( search: $searchTerm + assigneeUsername: $assigneeUsername statuses: $statuses sort: $sort first: $firstPageSize 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 5a6faea5cd8..40ec4c56171 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,6 +1,6 @@ -query getAlertsCount($searchTerm: String, $projectPath: ID!) { +query getAlertsCount($searchTerm: String, $projectPath: ID!, $assigneeUsername: String = "") { project(fullPath: $projectPath) { - alertManagementAlertStatusCounts(search: $searchTerm) { + alertManagementAlertStatusCounts(search: $searchTerm, assigneeUsername: $assigneeUsername) { all open acknowledged diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js index e180ab5f7e3..e34450204fb 100644 --- a/app/assets/javascripts/alert_management/list.js +++ b/app/assets/javascripts/alert_management/list.js @@ -18,12 +18,12 @@ export default () => { populatingAlertsHelpUrl, alertsHelpUrl, opsgenieMvcTargetUrl, + textQuery, + assigneeUsernameQuery, + alertManagementEnabled, + userCanEnableAlertManagement, + opsgenieMvcEnabled, } = domEl.dataset; - let { alertManagementEnabled, userCanEnableAlertManagement, opsgenieMvcEnabled } = domEl.dataset; - - alertManagementEnabled = parseBoolean(alertManagementEnabled); - userCanEnableAlertManagement = parseBoolean(userCanEnableAlertManagement); - opsgenieMvcEnabled = parseBoolean(opsgenieMvcEnabled); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( @@ -50,23 +50,24 @@ export default () => { return new Vue({ el: selector, + provide: { + projectPath, + textQuery, + assigneeUsernameQuery, + enableAlertManagementPath, + populatingAlertsHelpUrl, + emptyAlertSvgPath, + opsgenieMvcTargetUrl, + alertManagementEnabled: parseBoolean(alertManagementEnabled), + userCanEnableAlertManagement: parseBoolean(userCanEnableAlertManagement), + opsgenieMvcEnabled: parseBoolean(opsgenieMvcEnabled), + }, apolloProvider, components: { AlertManagementList, }, render(createElement) { - return createElement('alert-management-list', { - props: { - projectPath, - enableAlertManagementPath, - populatingAlertsHelpUrl, - emptyAlertSvgPath, - alertManagementEnabled, - userCanEnableAlertManagement, - opsgenieMvcTargetUrl, - opsgenieMvcEnabled, - }, - }); + return createElement('alert-management-list'); }, }); }; diff --git a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue index c5e213d7dc9..f2394ce385f 100644 --- a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue +++ b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue @@ -180,11 +180,9 @@ export default { /> </span> </div> - <span class="gl-display-flex gl-justify-content-end"> - <gl-button v-gl-modal.authKeyModal class="gl-mt-2" :disabled="isDisabled">{{ - $options.RESET_KEY - }}</gl-button> - </span> + <gl-button v-gl-modal.authKeyModal class="gl-mt-2" :disabled="isDisabled">{{ + $options.RESET_KEY + }}</gl-button> <gl-modal modal-id="authKeyModal" :title="$options.RESET_KEY" diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue new file mode 100644 index 00000000000..217442e6131 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue @@ -0,0 +1,109 @@ +<script> +import { GlTable, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import Tracking from '~/tracking'; +import { trackAlertIntergrationsViewsOptions } from '../constants'; + +export const i18n = { + title: s__('AlertsIntegrations|Current integrations'), + emptyState: s__('AlertsIntegrations|No integrations have been added yet'), + status: { + enabled: { + name: __('Enabled'), + tooltip: s__('AlertsIntegrations|Alerts will be created through this integration'), + }, + disabled: { + name: __('Disabled'), + tooltip: s__('AlertsIntegrations|Alerts will not be created through this integration'), + }, + }, +}; + +const bodyTrClass = + 'gl-border-1 gl-border-t-solid gl-border-b-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-blue-200'; + +export default { + i18n, + components: { + GlTable, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + integrations: { + type: Array, + required: false, + default: () => [], + }, + }, + fields: [ + { + key: 'activated', + label: __('Status'), + }, + { + key: 'name', + label: s__('AlertsIntegrations|Integration Name'), + }, + { + key: 'type', + label: __('Type'), + }, + ], + computed: { + tbodyTrClass() { + return { + [bodyTrClass]: this.integrations.length, + }; + }, + }, + mounted() { + this.trackPageViews(); + }, + methods: { + trackPageViews() { + const { category, action } = trackAlertIntergrationsViewsOptions; + Tracking.event(category, action); + }, + }, +}; +</script> + +<template> + <div class="incident-management-list"> + <h5 class="gl-font-lg">{{ $options.i18n.title }}</h5> + <gl-table + :empty-text="$options.i18n.emptyState" + :items="integrations" + :fields="$options.fields" + stacked="md" + :tbody-tr-class="tbodyTrClass" + show-empty + > + <template #cell(activated)="{ item }"> + <span v-if="item.activated" data-testid="integration-activated-status"> + <gl-icon + v-gl-tooltip + name="check-circle-filled" + :size="16" + class="gl-text-green-500 gl-hover-cursor-pointer gl-mr-3" + :title="$options.i18n.status.enabled.tooltip" + /> + {{ $options.i18n.status.enabled.name }} + </span> + <span v-else data-testid="integration-activated-status"> + <gl-icon + v-gl-tooltip + name="warning-solid" + :size="16" + class="gl-text-red-600 gl-hover-cursor-pointer gl-mr-3" + :title="$options.i18n.status.disabled.tooltip" + /> + {{ $options.i18n.status.disabled.name }} + </span> + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index f0bb8b0a90f..f885afae378 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -14,9 +14,11 @@ import { GlFormSelect, } from '@gitlab/ui'; import { debounce } from 'lodash'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { s__ } from '~/locale'; +import { doesHashExistInUrl } from '~/lib/utils/url_utility'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue'; +import IntegrationsList from './alerts_integrations_list.vue'; import csrf from '~/lib/utils/csrf'; import service from '../services'; import { @@ -25,7 +27,9 @@ import { JSON_VALIDATE_DELAY, targetPrometheusUrlPlaceholder, targetOpsgenieUrlPlaceholder, + sectionHash, } from '../constants'; +import createFlash, { FLASH_TYPES } from '~/flash'; export default { i18n, @@ -46,11 +50,11 @@ export default { GlSprintf, ClipboardButton, ToggleButton, + IntegrationsList, }, directives: { 'gl-modal': GlModalDirective, }, - mixins: [glFeatureFlagsMixin()], inject: ['prometheus', 'generic', 'opsgenie'], data() { return { @@ -148,6 +152,20 @@ export default { ? this.$options.targetOpsgenieUrlPlaceholder : this.$options.targetPrometheusUrlPlaceholder; }, + integrations() { + return [ + { + name: s__('AlertSettings|HTTP endpoint'), + type: s__('AlertsIntegrations|HTTP endpoint'), + activated: this.generic.activated, + }, + { + name: s__('AlertSettings|External Prometheus'), + type: s__('AlertsIntegrations|Prometheus'), + activated: this.prometheus.activated, + }, + ]; + }, }, watch: { 'testAlert.json': debounce(function debouncedJsonValidate() { @@ -173,9 +191,12 @@ export default { this.authKey = this.selectedService.authKey ?? ''; }, methods: { - createUserErrorMessage(errors = { error: [''] }) { - // eslint-disable-next-line prefer-destructuring - this.serverError = errors.error[0]; + createUserErrorMessage(errors = {}) { + const error = Object.entries(errors)?.[0]; + if (error) { + const [field, [msg]] = error; + this.serverError = `${field} ${msg}`; + } }, setOpsgenieAsDefault() { this.options = this.options.map(el => { @@ -245,29 +266,11 @@ export default { ? { service: { opsgenie_mvc_target_url: this.targetUrl, opsgenie_mvc_enabled: value } } : { service: { active: value } }, }) - .then(() => { - this.active = 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); - }) + .then(() => this.notifySuccessAndReload()) .catch(({ response: { data: { errors } = {} } = {} }) => { this.createUserErrorMessage(errors); this.setFeedback({ - feedbackMessage: `${this.$options.i18n.errorMsg}.`, + feedbackMessage: this.$options.i18n.errorMsg, variant: 'danger', }); }) @@ -276,6 +279,12 @@ export default { this.canSaveForm = false; }); }, + reload() { + if (!doesHashExistInUrl(sectionHash)) { + window.location.hash = sectionHash; + } + window.location.reload(); + }, togglePrometheusActive(value) { this.loading = true; return service @@ -288,15 +297,11 @@ export default { redirect: window.location, }, }) - .then(() => { - this.active = value; - this.toggleSuccess(value); - this.removeOpsGenieOption(); - }) + .then(() => this.notifySuccessAndReload()) .catch(({ response: { data: { errors } = {} } = {} }) => { this.createUserErrorMessage(errors); this.setFeedback({ - feedbackMessage: `${this.$options.i18n.errorMsg}.`, + feedbackMessage: this.$options.i18n.errorMsg, variant: 'danger', }); }) @@ -305,18 +310,9 @@ export default { 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', - }); - } + notifySuccessAndReload() { + createFlash({ message: this.$options.i18n.changesSaved, type: FLASH_TYPES.NOTICE }); + setTimeout(() => this.reload(), 1000); }, setFeedback({ feedbackMessage, variant }) { this.feedback = { feedbackMessage, variant }; @@ -375,47 +371,50 @@ export default { <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(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> + <integrations-list :integrations="integrations" /> + <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset"> - <gl-form-group - :label="$options.i18n.integrationsLabel" - label-for="integrations" - label-class="label-bold" - > + <h5 class="gl-font-lg gl-my-5">{{ $options.i18n.integrationsLabel }}</h5> + + <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(active)" + > + {{ __('Save anyway') }} + </gl-button> + </gl-alert> + + <div data-testid="alert-settings-description"> + <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-group label-for="integration-type" :label="$options.i18n.integration"> <gl-form-select + id="integration-type" v-model="selectedEndpoint" :options="options" data-testid="alert-settings-select" @change="resetFormValues" /> - <span class="gl-text-gray-200"> + <span class="gl-text-gray-500"> <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" + href="https://gitlab.com/groups/gitlab-org/-/epics/4390" target="_blank" >{{ content }}</gl-link > @@ -423,11 +422,7 @@ export default { </gl-sprintf> </span> </gl-form-group> - <gl-form-group - :label="$options.i18n.activeLabel" - label-for="activated" - label-class="label-bold" - > + <gl-form-group :label="$options.i18n.activeLabel" label-for="activated"> <toggle-button id="activated" :disabled-input="loading" @@ -440,7 +435,6 @@ export default { v-if="isOpsgenie || isPrometheus" :label="$options.i18n.apiBaseUrlLabel" label-for="api-url" - label-class="label-bold" > <gl-form-input id="api-url" @@ -449,12 +443,12 @@ export default { :placeholder="baseUrlPlaceholder" :disabled="!active" /> - <span class="gl-text-gray-200"> + <span class="gl-text-gray-500"> {{ $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-group :label="$options.i18n.urlLabel" label-for="url"> <gl-form-input-group id="url" readonly :value="selectedService.url"> <template #append> <clipboard-button @@ -464,15 +458,11 @@ export default { /> </template> </gl-form-input-group> - <span class="gl-text-gray-200"> + <span class="gl-text-gray-500"> {{ prometheusInfo }} </span> </gl-form-group> - <gl-form-group - :label="$options.i18n.authKeyLabel" - label-for="authorization-key" - label-class="label-bold" - > + <gl-form-group :label="$options.i18n.authKeyLabel" label-for="authorization-key"> <gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="authKey"> <template #append> <clipboard-button @@ -498,7 +488,6 @@ export default { <gl-form-group :label="$options.i18n.alertJson" label-for="alert-json" - label-class="label-bold" :invalid-feedback="testAlert.error" > <gl-form-textarea @@ -511,16 +500,11 @@ export default { max-rows="10" /> </gl-form-group> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{ - $options.i18n.testAlertInfo - }}</gl-button> - </div> + <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{ + $options.i18n.testAlertInfo + }}</gl-button> </template> <div class="footer-block row-content-block gl-display-flex gl-justify-content-space-between"> - <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset"> - {{ __('Cancel') }} - </gl-button> <gl-button variant="success" category="primary" @@ -529,6 +513,9 @@ export default { > {{ __('Save changes') }} </gl-button> + <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset"> + {{ __('Cancel') }} + </gl-button> </div> </gl-form> </div> diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js index fc669995875..4220dbde0c7 100644 --- a/app/assets/javascripts/alerts_settings/constants.js +++ b/app/assets/javascripts/alerts_settings/constants.js @@ -7,22 +7,21 @@ export const i18n = { 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'), + 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.'), + changesSaved: s__('AlertSettings|Your integration was 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}', + 'AlertSettings|Learn more about our improvements for %{linkStart}integrations%{linkEnd}', ), resetKey: s__('AlertSettings|Reset key'), copyToClipboard: s__('AlertSettings|Copy'), - integrationsLabel: s__('AlertSettings|Integrations'), + integrationsLabel: s__('AlertSettings|Add new integrations'), apiBaseUrlLabel: s__('AlertSettings|API URL'), authKeyLabel: s__('AlertSettings|Authorization key'), urlLabel: s__('AlertSettings|Webhook URL'), @@ -38,10 +37,11 @@ export const i18n = { authKeyRest: s__( 'AlertSettings|Authorization key has been successfully reset. Please save your changes now.', ), + integration: s__('AlertSettings|Integration'), }; export const serviceOptions = [ - { value: 'generic', text: s__('AlertSettings|Generic') }, + { value: 'generic', text: s__('AlertSettings|HTTP Endpoint') }, { value: 'prometheus', text: s__('AlertSettings|External Prometheus') }, { value: 'opsgenie', text: s__('AlertSettings|Opsgenie') }, ]; @@ -50,3 +50,15 @@ export const JSON_VALIDATE_DELAY = 250; export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/'; export const targetOpsgenieUrlPlaceholder = 'https://app.opsgenie.com/alert/list/'; + +export const sectionHash = 'js-alert-management-settings'; + +/* eslint-disable @gitlab/require-i18n-strings */ + +/** + * Tracks snowplow event when user views alerts intergration list + */ +export const trackAlertIntergrationsViewsOptions = { + category: 'Alert Intergrations', + action: 'view_alert_integrations_list', +}; diff --git a/app/assets/javascripts/analytics/instance_statistics/components/app.vue b/app/assets/javascripts/analytics/instance_statistics/components/app.vue new file mode 100644 index 00000000000..7aa5c98aa0b --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/app.vue @@ -0,0 +1,30 @@ +<script> +import InstanceCounts from './instance_counts.vue'; +import PipelinesChart from './pipelines_chart.vue'; +import UsersChart from './users_chart.vue'; +import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants'; + +export default { + name: 'InstanceStatisticsApp', + components: { + InstanceCounts, + PipelinesChart, + UsersChart, + }, + TOTAL_DAYS_TO_SHOW, + START_DATE, + TODAY, +}; +</script> + +<template> + <div> + <instance-counts /> + <users-chart + :start-date="$options.START_DATE" + :end-date="$options.TODAY" + :total-data-points="$options.TOTAL_DAYS_TO_SHOW" + /> + <pipelines-chart /> + </div> +</template> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue b/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue new file mode 100644 index 00000000000..4fbfb4daf22 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue @@ -0,0 +1,64 @@ +<script> +import * as Sentry from '~/sentry/wrapper'; +import { s__ } from '~/locale'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; +import MetricCard from '~/analytics/shared/components/metric_card.vue'; +import instanceStatisticsCountQuery from '../graphql/queries/instance_statistics_count.query.graphql'; + +const defaultPrecision = 0; + +export default { + name: 'InstanceCounts', + components: { + MetricCard, + }, + data() { + return { + counts: [], + }; + }, + apollo: { + counts: { + query: instanceStatisticsCountQuery, + update(data) { + return Object.entries(data).map(([key, obj]) => { + const label = this.$options.i18n.labels[key]; + const formatter = getFormatter(SUPPORTED_FORMATS.number); + const value = obj.nodes?.length ? formatter(obj.nodes[0].count, defaultPrecision) : null; + + return { + key, + value, + label, + }; + }); + }, + error(error) { + createFlash(this.$options.i18n.loadCountsError); + Sentry.captureException(error); + }, + }, + }, + i18n: { + labels: { + users: s__('InstanceStatistics|Users'), + projects: s__('InstanceStatistics|Projects'), + groups: s__('InstanceStatistics|Groups'), + issues: s__('InstanceStatistics|Issues'), + mergeRequests: s__('InstanceStatistics|Merge Requests'), + pipelines: s__('InstanceStatistics|Pipelines'), + }, + loadCountsError: s__('Could not load instance counts. Please refresh the page to try again.'), + }, +}; +</script> + +<template> + <metric-card + :title="__('Instance Statistics')" + :metrics="counts" + :is-loading="$apollo.queries.counts.loading" + class="gl-mt-4" + /> +</template> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue new file mode 100644 index 00000000000..b16d960402b --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue @@ -0,0 +1,215 @@ +<script> +import { GlLineChart } from '@gitlab/ui/dist/charts'; +import { GlAlert } from '@gitlab/ui'; +import { mapKeys, mapValues, pick, some, sum } from 'lodash'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { s__ } from '~/locale'; +import { + differenceInMonths, + formatDateAsMonth, + getDayDifference, +} from '~/lib/utils/datetime_utility'; +import { getAverageByMonth, sortByDate, extractValues } from '../utils'; +import pipelineStatsQuery from '../graphql/queries/pipeline_stats.query.graphql'; +import { TODAY, START_DATE } from '../constants'; + +const DATA_KEYS = [ + 'pipelinesTotal', + 'pipelinesSucceeded', + 'pipelinesFailed', + 'pipelinesCanceled', + 'pipelinesSkipped', +]; +const PREFIX = 'pipelines'; + +export default { + name: 'PipelinesChart', + components: { + GlLineChart, + GlAlert, + ChartSkeletonLoader, + }, + startDate: START_DATE, + endDate: TODAY, + i18n: { + loadPipelineChartError: s__( + 'InstanceAnalytics|Could not load the pipelines chart. Please refresh the page to try again.', + ), + noDataMessage: s__('InstanceAnalytics|There is no data available.'), + total: s__('InstanceAnalytics|Total'), + succeeded: s__('InstanceAnalytics|Succeeded'), + failed: s__('InstanceAnalytics|Failed'), + canceled: s__('InstanceAnalytics|Canceled'), + skipped: s__('InstanceAnalytics|Skipped'), + chartTitle: s__('InstanceAnalytics|Pipelines'), + yAxisTitle: s__('InstanceAnalytics|Items'), + xAxisTitle: s__('InstanceAnalytics|Month'), + }, + data() { + return { + loading: true, + loadingError: null, + }; + }, + apollo: { + pipelineStats: { + query: pipelineStatsQuery, + variables() { + return { + firstTotal: this.totalDaysToShow, + firstSucceeded: this.totalDaysToShow, + firstFailed: this.totalDaysToShow, + firstCanceled: this.totalDaysToShow, + firstSkipped: this.totalDaysToShow, + }; + }, + update(data) { + const allData = extractValues(data, DATA_KEYS, PREFIX, 'nodes'); + const allPageInfo = extractValues(data, DATA_KEYS, PREFIX, 'pageInfo'); + + return { + ...mapValues(allData, sortByDate), + ...allPageInfo, + }; + }, + result() { + if (this.hasNextPage) { + this.fetchNextPage(); + } + }, + error() { + this.handleError(); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.pipelineStats.loading; + }, + totalDaysToShow() { + return getDayDifference(this.$options.startDate, this.$options.endDate); + }, + firstVariables() { + const allData = pick(this.pipelineStats, [ + 'nodesTotal', + 'nodesSucceeded', + 'nodesFailed', + 'nodesCanceled', + 'nodesSkipped', + ]); + const allDayDiffs = mapValues(allData, data => { + const firstdataPoint = data[0]; + if (!firstdataPoint) { + return 0; + } + + return Math.max( + 0, + getDayDifference(this.$options.startDate, new Date(firstdataPoint.recordedAt)), + ); + }); + + return mapKeys(allDayDiffs, (value, key) => key.replace('nodes', 'first')); + }, + cursorVariables() { + const pageInfoKeys = [ + 'pageInfoTotal', + 'pageInfoSucceeded', + 'pageInfoFailed', + 'pageInfoCanceled', + 'pageInfoSkipped', + ]; + + return extractValues(this.pipelineStats, pageInfoKeys, 'pageInfo', 'endCursor'); + }, + hasNextPage() { + return ( + sum(Object.values(this.firstVariables)) > 0 && + some(this.pipelineStats, ({ hasNextPage }) => hasNextPage) + ); + }, + hasEmptyDataSet() { + return this.chartData.every(({ data }) => data.length === 0); + }, + chartData() { + const allData = pick(this.pipelineStats, [ + 'nodesTotal', + 'nodesSucceeded', + 'nodesFailed', + 'nodesCanceled', + 'nodesSkipped', + ]); + const options = { shouldRound: true }; + return Object.keys(allData).map(key => { + const i18nName = key.slice('nodes'.length).toLowerCase(); + return { + name: this.$options.i18n[i18nName], + data: getAverageByMonth(allData[key], options), + }; + }); + }, + range() { + return { + min: this.$options.startDate, + max: this.$options.endDate, + }; + }, + chartOptions() { + const { endDate, startDate, i18n } = this.$options; + return { + xAxis: { + ...this.range, + name: i18n.xAxisTitle, + type: 'time', + splitNumber: differenceInMonths(startDate, endDate) + 1, + axisLabel: { + interval: 0, + showMinLabel: false, + showMaxLabel: false, + align: 'right', + formatter: formatDateAsMonth, + }, + }, + yAxis: { + name: i18n.yAxisTitle, + }, + }; + }, + }, + methods: { + handleError() { + this.loadingError = true; + }, + fetchNextPage() { + this.$apollo.queries.pipelineStats + .fetchMore({ + variables: { + ...this.firstVariables, + ...this.cursorVariables, + }, + updateQuery: (previousResult, { fetchMoreResult }) => { + return Object.keys(fetchMoreResult).reduce((memo, key) => { + const { nodes, ...rest } = fetchMoreResult[key]; + const previousNodes = previousResult[key].nodes; + return { ...memo, [key]: { ...rest, nodes: [...previousNodes, ...nodes] } }; + }, {}); + }, + }) + .catch(this.handleError); + }, + }, +}; +</script> +<template> + <div> + <h3>{{ $options.i18n.chartTitle }}</h3> + <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3"> + {{ this.$options.i18n.loadPipelineChartError }} + </gl-alert> + <chart-skeleton-loader v-else-if="isLoading" /> + <gl-alert v-else-if="hasEmptyDataSet" variant="info" :dismissible="false" class="gl-mt-3"> + {{ $options.i18n.noDataMessage }} + </gl-alert> + <gl-line-chart v-else :option="chartOptions" :include-legend-avg-max="true" :data="chartData" /> + </div> +</template> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue new file mode 100644 index 00000000000..a4a1d40b70b --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue @@ -0,0 +1,143 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import produce from 'immer'; +import { sortBy } from 'lodash'; +import * as Sentry from '~/sentry/wrapper'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { __ } from '~/locale'; +import { formatDateAsMonth } from '~/lib/utils/datetime_utility'; +import usersQuery from '../graphql/queries/users.query.graphql'; +import { getAverageByMonth } from '../utils'; + +const sortByDate = data => sortBy(data, item => new Date(item[0]).getTime()); + +export default { + name: 'UsersChart', + components: { GlAlert, GlAreaChart, ChartSkeletonLoader }, + props: { + startDate: { + type: Date, + required: true, + }, + endDate: { + type: Date, + required: true, + }, + totalDataPoints: { + type: Number, + required: true, + }, + }, + data() { + return { + loadingError: null, + users: [], + pageInfo: null, + }; + }, + apollo: { + users: { + query: usersQuery, + variables() { + return { + first: this.totalDataPoints, + after: null, + }; + }, + update(data) { + return data.users?.nodes || []; + }, + result({ data }) { + const { + users: { pageInfo }, + } = data; + this.pageInfo = pageInfo; + this.fetchNextPage(); + }, + error(error) { + this.handleError(error); + }, + }, + }, + i18n: { + yAxisTitle: __('Total users'), + xAxisTitle: __('Month'), + loadUserChartError: __('Could not load the user chart. Please refresh the page to try again.'), + noDataMessage: __('There is no data available.'), + }, + computed: { + isLoading() { + return this.$apollo.queries.users.loading || this.pageInfo?.hasNextPage; + }, + chartUserData() { + const averaged = getAverageByMonth( + this.users.length > this.totalDataPoints + ? this.users.slice(0, this.totalDataPoints) + : this.users, + { shouldRound: true }, + ); + return sortByDate(averaged); + }, + options() { + return { + xAxis: { + name: this.$options.i18n.xAxisTitle, + type: 'category', + axisLabel: { + formatter: formatDateAsMonth, + }, + }, + yAxis: { + name: this.$options.i18n.yAxisTitle, + }, + }; + }, + }, + methods: { + handleError(error) { + this.loadingError = true; + this.users = []; + Sentry.captureException(error); + }, + fetchNextPage() { + if (this.pageInfo?.hasNextPage) { + this.$apollo.queries.users + .fetchMore({ + variables: { first: this.totalDataPoints, after: this.pageInfo.endCursor }, + updateQuery: (previousResult, { fetchMoreResult }) => { + return produce(fetchMoreResult, newUsers => { + // eslint-disable-next-line no-param-reassign + newUsers.users.nodes = [...previousResult.users.nodes, ...newUsers.users.nodes]; + }); + }, + }) + .catch(this.handleError); + } + }, + }, +}; +</script> +<template> + <div> + <h3>{{ $options.i18n.yAxisTitle }}</h3> + <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3"> + {{ this.$options.i18n.loadUserChartError }} + </gl-alert> + <chart-skeleton-loader v-else-if="isLoading" /> + <gl-alert v-else-if="!chartUserData.length" variant="info" :dismissible="false" class="gl-mt-3"> + {{ $options.i18n.noDataMessage }} + </gl-alert> + <gl-area-chart + v-else + :option="options" + :include-legend-avg-max="true" + :data="[ + { + name: $options.i18n.yAxisTitle, + data: chartUserData, + }, + ]" + /> + </div> +</template> diff --git a/app/assets/javascripts/analytics/instance_statistics/constants.js b/app/assets/javascripts/analytics/instance_statistics/constants.js new file mode 100644 index 00000000000..846c0ef408b --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/constants.js @@ -0,0 +1,5 @@ +import { getDateInPast } from '~/lib/utils/datetime_utility'; + +export const TOTAL_DAYS_TO_SHOW = 365; +export const TODAY = new Date(); +export const START_DATE = getDateInPast(TODAY, TOTAL_DAYS_TO_SHOW); diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql new file mode 100644 index 00000000000..40cef95c2e7 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql @@ -0,0 +1,4 @@ +fragment Count on InstanceStatisticsMeasurement { + count + recordedAt +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql new file mode 100644 index 00000000000..40cef95c2e7 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql @@ -0,0 +1,4 @@ +fragment Count on InstanceStatisticsMeasurement { + count + recordedAt +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql new file mode 100644 index 00000000000..f14c2658674 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql @@ -0,0 +1,34 @@ +#import "../fragments/count.fragment.graphql" + +query getInstanceCounts { + projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: 1) { + nodes { + ...Count + } + } + groups: instanceStatisticsMeasurements(identifier: GROUPS, first: 1) { + nodes { + ...Count + } + } + users: instanceStatisticsMeasurements(identifier: USERS, first: 1) { + nodes { + ...Count + } + } + issues: instanceStatisticsMeasurements(identifier: ISSUES, first: 1) { + nodes { + ...Count + } + } + mergeRequests: instanceStatisticsMeasurements(identifier: MERGE_REQUESTS, first: 1) { + nodes { + ...Count + } + } + pipelines: instanceStatisticsMeasurements(identifier: PIPELINES, first: 1) { + nodes { + ...Count + } + } +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql new file mode 100644 index 00000000000..3bf40403f91 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql @@ -0,0 +1,76 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "./count.fragment.graphql" + +query pipelineStats( + $firstTotal: Int + $firstSucceeded: Int + $firstFailed: Int + $firstCanceled: Int + $firstSkipped: Int + $endCursorTotal: String + $endCursorSucceeded: String + $endCursorFailed: String + $endCursorCanceled: String + $endCursorSkipped: String +) { + pipelinesTotal: instanceStatisticsMeasurements( + identifier: PIPELINES + first: $firstTotal + after: $endCursorTotal + ) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } + pipelinesSucceeded: instanceStatisticsMeasurements( + identifier: PIPELINES_SUCCEEDED + first: $firstSucceeded + after: $endCursorSucceeded + ) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } + pipelinesFailed: instanceStatisticsMeasurements( + identifier: PIPELINES_FAILED + first: $firstFailed + after: $endCursorFailed + ) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } + pipelinesCanceled: instanceStatisticsMeasurements( + identifier: PIPELINES_CANCELED + first: $firstCanceled + after: $endCursorCanceled + ) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } + pipelinesSkipped: instanceStatisticsMeasurements( + identifier: PIPELINES_SKIPPED + first: $firstSkipped + after: $endCursorSkipped + ) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql new file mode 100644 index 00000000000..6235e36eb89 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql @@ -0,0 +1,13 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/count.fragment.graphql" + +query getUsersCount($first: Int, $after: String) { + users: instanceStatisticsMeasurements(identifier: USERS, first: $first, after: $after) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/analytics/instance_statistics/index.js b/app/assets/javascripts/analytics/instance_statistics/index.js new file mode 100644 index 00000000000..0d7dcf6ace8 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import InstanceStatisticsApp from './components/app.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default () => { + const el = document.getElementById('js-instance-statistics-app'); + + if (!el) return false; + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(InstanceStatisticsApp); + }, + }); +}; diff --git a/app/assets/javascripts/analytics/instance_statistics/utils.js b/app/assets/javascripts/analytics/instance_statistics/utils.js new file mode 100644 index 00000000000..907482c0c72 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/utils.js @@ -0,0 +1,69 @@ +import { masks } from 'dateformat'; +import { mapKeys, mapValues, pick, sortBy } from 'lodash'; +import { formatDate } from '~/lib/utils/datetime_utility'; + +const { isoDate } = masks; + +/** + * Takes an array of items and returns one item per month with the average of the `count`s from that month + * @param {Array} items + * @param {Number} items[index].count value to be averaged + * @param {String} items[index].recordedAt item dateTime time stamp to be collected into a month + * @param {Object} options + * @param {Object} options.shouldRound an option to specify whether the retuned averages should be rounded + * @return {Array} items collected into [month, average], + * where month is a dateTime string representing the first of the given month + * and average is the average of the count + */ +export function getAverageByMonth(items = [], options = {}) { + const { shouldRound = false } = options; + const itemsMap = items.reduce((memo, item) => { + const { count, recordedAt } = item; + const date = new Date(recordedAt); + const month = formatDate(new Date(date.getFullYear(), date.getMonth(), 1), isoDate); + if (memo[month]) { + const { sum, recordCount } = memo[month]; + return { ...memo, [month]: { sum: sum + count, recordCount: recordCount + 1 } }; + } + + return { ...memo, [month]: { sum: count, recordCount: 1 } }; + }, {}); + + return Object.keys(itemsMap).map(month => { + const { sum, recordCount } = itemsMap[month]; + const avg = sum / recordCount; + if (shouldRound) { + return [month, Math.round(avg)]; + } + + return [month, avg]; + }); +} + +/** + * Extracts values given a data set and a set of keys + * @example + * const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' }; + * extractValues(data, ['fooBar'], 'foo', 'baz') => { bazBar: 'quis' } + * @param {Object} data set to extract values from + * @param {Array} dataKeys keys describing where to look for values in the data set + * @param {String} replaceKey name key to be replaced in the data set + * @param {String} nestedKey key nested in the data set to be extracted, + * this is also used to rename the newly created data set + * @return {Object} the newly created data set with the extracted values + */ +export function extractValues(data, dataKeys = [], replaceKey, nestedKey) { + return mapKeys(pick(mapValues(data, nestedKey), dataKeys), (value, key) => + key.replace(replaceKey, nestedKey), + ); +} + +/** + * Creates a new array of items sorted by the date string of each item + * @param {Array} items [description] + * @param {String} items[0] date string + * @return {Array} the new sorted array. + */ +export function sortByDate(items = []) { + return sortBy(items, ({ recordedAt }) => new Date(recordedAt).getTime()); +} diff --git a/app/assets/javascripts/analytics/shared/components/metric_card.vue b/app/assets/javascripts/analytics/shared/components/metric_card.vue new file mode 100644 index 00000000000..cee186c057c --- /dev/null +++ b/app/assets/javascripts/analytics/shared/components/metric_card.vue @@ -0,0 +1,80 @@ +<script> +import { + GlCard, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlLink, + GlIcon, + GlTooltipDirective, +} from '@gitlab/ui'; + +export default { + name: 'MetricCard', + components: { + GlCard, + GlSkeletonLoading, + GlLink, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + title: { + type: String, + required: true, + }, + metrics: { + type: Array, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + valueText(metric) { + const { value = null, unit = null } = metric; + if (!value || value === '-') return '-'; + return unit && value ? `${value} ${unit}` : value; + }, + }, +}; +</script> +<template> + <gl-card> + <template #header> + <strong ref="title">{{ title }}</strong> + </template> + <template #default> + <gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3" /> + <div v-else ref="metricsWrapper" class="gl-display-flex"> + <div + v-for="metric in metrics" + :key="metric.key" + ref="metricItem" + class="js-metric-card-item gl-flex-grow-1 gl-text-center" + > + <gl-link v-if="metric.link" :href="metric.link"> + <h3 class="gl-my-2 gl-text-blue-700">{{ valueText(metric) }}</h3> + </gl-link> + <h3 v-else class="gl-my-2">{{ valueText(metric) }}</h3> + <p class="text-secondary gl-font-sm gl-mb-2"> + {{ metric.label }} + <span v-if="metric.tooltipText"> + + <gl-icon + v-gl-tooltip="{ title: metric.tooltipText }" + :size="14" + class="gl-vertical-align-middle" + name="question" + data-testid="tooltip" + /> + </span> + </p> + </div> + </div> + </template> + </gl-card> +</template> diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index dbc7ff67d9d..63b75cdb734 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -30,6 +30,7 @@ const Api = { projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches', projectSearchPath: '/api/:version/projects/:id/search', projectMilestonesPath: '/api/:version/projects/:id/milestones', + projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid', mergeRequestsPath: '/api/:version/merge_requests', groupLabelsPath: '/groups/:namespace_path/-/labels', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', @@ -54,6 +55,7 @@ const Api = { releaseLinkPath: '/api/:version/projects/:id/releases/:tag_name/assets/links/:link_id', mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines', adminStatisticsPath: '/api/:version/application/statistics', + pipelineJobsPath: '/api/:version/projects/:id/pipelines/:pipeline_id/jobs', pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id', pipelinesPath: '/api/:version/projects/:id/pipelines/', createPipelinePath: '/api/:version/projects/:id/pipeline', @@ -64,6 +66,10 @@ const Api = { issuePath: '/api/:version/projects/:id/issues/:issue_iid', tagsPath: '/api/:version/projects/:id/repository/tags', freezePeriodsPath: '/api/:version/projects/:id/freeze_periods', + usageDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users', + featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists', + featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid', + billableGroupMembersPath: '/api/:version/groups/:id/billable_members', group(groupId, callback = () => {}) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -111,6 +117,12 @@ const Api = { }); }, + inviteGroupMember(id, data) { + const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, data); + }, + groupMilestones(id, options) { const url = Api.buildUrl(this.groupMilestonesPath).replace(':id', encodeURIComponent(id)); @@ -317,6 +329,14 @@ const Api = { }); }, + addProjectIssueAsTodo(projectId, issueIid) { + const url = Api.buildUrl(Api.projectIssuePath) + .replace(':id', encodeURIComponent(projectId)) + .replace(':issue_iid', encodeURIComponent(issueIid)); + + return axios.post(`${url}/todo`); + }, + mergeRequests(params = {}) { const url = Api.buildUrl(Api.mergeRequestsPath); @@ -366,7 +386,7 @@ const Api = { }, commitMultiple(id, data) { - // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions + // see https://docs.gitlab.com/ee/api/commits.html#create-a-commit-with-multiple-files-and-actions const url = Api.buildUrl(Api.commitsPath).replace(':id', encodeURIComponent(id)); return axios.post(url, JSON.stringify(data), { headers: { @@ -590,6 +610,14 @@ const Api = { return axios.get(url); }, + pipelineJobs(projectId, pipelineId) { + const url = Api.buildUrl(this.pipelineJobsPath) + .replace(':id', encodeURIComponent(projectId)) + .replace(':pipeline_id', encodeURIComponent(pipelineId)); + + return axios.get(url); + }, + // Return all pipelines for a project or filter by query params pipelines(id, options = {}) { const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(id)); @@ -686,9 +714,78 @@ const Api = { return axios.post(url, freezePeriod); }, + trackRedisHllUserEvent(event) { + if (!gon.features?.usageDataApi) { + return null; + } + + const url = Api.buildUrl(this.usageDataIncrementUniqueUsersPath); + const headers = { + 'Content-Type': 'application/json', + }; + + return axios.post(url, { event }, { headers }); + }, + buildUrl(url) { return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); }, + + fetchFeatureFlagUserLists(id, page) { + const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id); + + return axios.get(url, { params: { page } }); + }, + + createFeatureFlagUserList(id, list) { + const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id); + + return axios.post(url, list); + }, + + fetchFeatureFlagUserList(id, listIid) { + const url = Api.buildUrl(this.featureFlagUserList) + .replace(':id', id) + .replace(':list_iid', listIid); + + return axios.get(url); + }, + + updateFeatureFlagUserList(id, list) { + const url = Api.buildUrl(this.featureFlagUserList) + .replace(':id', id) + .replace(':list_iid', list.iid); + + return axios.put(url, list); + }, + + deleteFeatureFlagUserList(id, listIid) { + const url = Api.buildUrl(this.featureFlagUserList) + .replace(':id', id) + .replace(':list_iid', listIid); + + return axios.delete(url); + }, + + fetchBillableGroupMembersList(namespaceId, options = {}, callback = () => {}) { + const url = Api.buildUrl(this.billableGroupMembersPath).replace(':id', namespaceId); + const defaults = { + per_page: DEFAULT_PER_PAGE, + page: 1, + }; + + return axios + .get(url, { + params: { + ...defaults, + ...options, + }, + }) + .then(({ data, headers }) => { + callback(data); + return { data, headers }; + }); + }, }; export default Api; diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index cb71047e00c..7055cd42978 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -572,7 +572,7 @@ export class AwardsHandler { } findMatchingEmojiElements(query) { - const emojiMatches = this.emoji.filterEmojiNamesByAlias(query); + const emojiMatches = this.emoji.searchEmoji(query, { match: 'fuzzy' }).map(({ name }) => name); const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]'); const $matchingElements = $emojiElements.filter( (i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0, diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index 6afb10dd2ad..0a8479519f1 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -218,7 +218,7 @@ export default { </p> </div> - <div v-if="isEditing" class="row-content-block gl-display-flex gl-justify-content-end"> + <div v-if="isEditing" class="row-content-block"> <gl-button class="btn-cancel gl-mr-4" data-testid="cancelEditing" @click="onCancel"> {{ __('Cancel') }} </gl-button> @@ -232,7 +232,7 @@ export default { {{ s__('Badges|Save changes') }} </gl-button> </div> - <div v-else class="gl-display-flex gl-justify-content-end form-group"> + <div v-else class="form-group"> <gl-button :loading="isSaving" type="submit" variant="success" category="primary"> {{ s__('Badges|Add badge') }} </gl-button> diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue index 3343634ecad..4ca91e7a589 100644 --- a/app/assets/javascripts/badges/components/badge_list_row.vue +++ b/app/assets/javascripts/badges/components/badge_list_row.vue @@ -1,6 +1,6 @@ <script> import { mapActions, mapState } from 'vuex'; -import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton, GlModalDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; import { PROJECT_BADGE } from '../constants'; import Badge from './badge.vue'; @@ -9,8 +9,11 @@ export default { name: 'BadgeListRow', components: { Badge, - GlIcon, GlLoadingIcon, + GlButton, + }, + directives: { + GlModal: GlModalDirective, }, props: { badge: { @@ -51,24 +54,25 @@ export default { <span class="table-section section-30 str-truncated">{{ badge.linkUrl }}</span> <div class="table-section section-10 table-button-footer"> <div v-if="canEditBadge" class="table-action-buttons"> - <button + <gl-button :disabled="badge.isDeleting" - class="btn btn-default gl-mr-3" - type="button" + class="gl-mr-3" + variant="default" + icon="pencil" + size="medium" + :aria-label="__('Edit')" @click="editBadge(badge)" - > - <gl-icon :size="16" :aria-label="__('Edit')" name="pencil" /> - </button> - <button + /> + <gl-button + v-gl-modal.delete-badge-modal :disabled="badge.isDeleting" - class="btn btn-danger" - type="button" - data-toggle="modal" - data-target="#delete-badge-modal" + variant="danger" + icon="remove" + size="medium" + :aria-label="__('Delete')" + data-testid="delete-badge" @click="updateBadgeInModal(badge)" - > - <gl-icon :size="16" :aria-label="__('Delete')" name="remove" /> - </button> + /> <gl-loading-icon v-show="badge.isDeleting" :inline="true" /> </div> </div> diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue index 531742e49e3..19781783100 100644 --- a/app/assets/javascripts/badges/components/badge_settings.vue +++ b/app/assets/javascripts/badges/components/badge_settings.vue @@ -1,9 +1,8 @@ <script> import { mapState, mapActions } from 'vuex'; -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlModal } from '@gitlab/ui'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; -import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import Badge from './badge.vue'; import BadgeForm from './badge_form.vue'; import BadgeList from './badge_list.vue'; @@ -14,7 +13,7 @@ export default { Badge, BadgeForm, BadgeList, - GlModal: DeprecatedModal2, + GlModal, GlSprintf, }, i18n: { @@ -24,6 +23,17 @@ export default { }, computed: { ...mapState(['badgeInModal', 'isEditing']), + primaryProps() { + return { + text: s__('Delete badge'), + attributes: [{ category: 'primary' }, { variant: 'danger' }], + }; + }, + cancelProps() { + return { + text: s__('Cancel'), + }; + }, }, methods: { ...mapActions(['deleteBadge']), @@ -44,11 +54,11 @@ export default { <template> <div class="badge-settings"> <gl-modal - id="delete-badge-modal" - :header-title-text="s__('Badges|Delete badge?')" - :footer-primary-button-text="s__('Badges|Delete badge')" - footer-primary-button-variant="danger" - @submit="onSubmitModal" + modal-id="delete-badge-modal" + :title="s__('Badges|Delete badge?')" + :action-primary="primaryProps" + :action-cancel="cancelProps" + @primary="onSubmitModal" > <div class="well"> <badge @@ -65,9 +75,9 @@ export default { </p> </gl-modal> - <badge-form v-show="isEditing" :is-editing="true" /> + <badge-form v-show="isEditing" :is-editing="true" data-testid="edit-badge" /> - <badge-form v-show="!isEditing" :is-editing="false" /> + <badge-form v-show="!isEditing" :is-editing="false" data-testid="add-new-badge" /> <badge-list v-show="!isEditing" /> </div> </template> diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index a6cd36caede..74069b61f07 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -18,11 +18,6 @@ export default { type: Object, required: true, }, - diffFile: { - type: Object, - required: false, - default: () => ({}), - }, line: { type: Object, required: false, diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue index 2b37ed19176..e18dc344cd7 100644 --- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -1,114 +1,43 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; -import { GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import { sprintf, n__ } from '~/locale'; -import DraftsCount from './drafts_count.vue'; -import PublishButton from './publish_button.vue'; +import { mapActions, mapGetters } from 'vuex'; +import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import PreviewItem from './preview_item.vue'; export default { components: { - GlButton, - GlLoadingIcon, + GlDropdown, + GlDropdownItem, GlIcon, - DraftsCount, - PublishButton, PreviewItem, }, computed: { - ...mapGetters(['isNotesFetched']), ...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']), - ...mapState('batchComments', ['showPreviewDropdown']), - dropdownTitle() { - return sprintf( - n__('%{count} pending comment', '%{count} pending comments', this.draftsCount), - { count: this.draftsCount }, - ); - }, - }, - watch: { - showPreviewDropdown() { - if (this.showPreviewDropdown && this.$refs.dropdown) { - this.$nextTick(() => this.$refs.dropdown.$el.focus()); - } - }, - }, - mounted() { - document.addEventListener('click', this.onClickDocument); - }, - beforeDestroy() { - document.removeEventListener('click', this.onClickDocument); }, methods: { - ...mapActions('batchComments', ['toggleReviewDropdown']), + ...mapActions('batchComments', ['scrollToDraft']), isLast(index) { return index === this.sortedDrafts.length - 1; }, - onClickDocument({ target }) { - if ( - this.showPreviewDropdown && - !target.closest('.review-preview-dropdown, .js-publish-draft-button') - ) { - this.toggleReviewDropdown(); - } - }, }, }; </script> <template> - <div - class="dropdown float-right review-preview-dropdown" - :class="{ - show: showPreviewDropdown, - }" + <gl-dropdown + :header-text="n__('%d pending comment', '%d pending comments', draftsCount)" + dropup + toggle-class="qa-review-preview-toggle" > - <gl-button - ref="dropdown" - type="button" - category="primary" - variant="success" - class="review-preview-dropdown-toggle qa-review-preview-toggle" - @click="toggleReviewDropdown" - > - {{ __('Finish review') }} - <drafts-count /> - <gl-icon name="angle-up" /> - </gl-button> - <div - class="dropdown-menu dropdown-menu-large dropdown-menu-right dropdown-open-top" - :class="{ - show: showPreviewDropdown, - }" + <template #button-content> + {{ __('Pending comments') }} + <gl-icon class="dropdown-chevron" name="chevron-up" /> + </template> + <gl-dropdown-item + v-for="(draft, index) in sortedDrafts" + :key="draft.id" + @click="scrollToDraft(draft)" > - <div class="dropdown-title gl-display-flex gl-align-items-center"> - <span class="gl-ml-auto">{{ dropdownTitle }}</span> - <gl-button - :aria-label="__('Close')" - type="button" - category="tertiary" - size="small" - class="dropdown-title-button gl-ml-auto gl-p-0!" - icon="close" - @click="toggleReviewDropdown" - /> - </div> - <div class="dropdown-content"> - <ul v-if="isNotesFetched"> - <li v-for="(draft, index) in sortedDrafts" :key="draft.id"> - <preview-item :draft="draft" :is-last="isLast(index)" /> - </li> - </ul> - <gl-loading-icon v-else size="lg" class="gl-mt-3 gl-mb-3" /> - </div> - <div class="dropdown-footer"> - <publish-button - :show-count="false" - :should-publish="true" - :label="__('Submit review')" - class="float-right gl-mr-3" - /> - </div> - </div> - </div> + <preview-item :draft="draft" :is-last="isLast(index)" /> + </gl-dropdown-item> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue index c89a6b537ef..dca6d90fbcb 100644 --- a/app/assets/javascripts/batch_comments/components/preview_item.vue +++ b/app/assets/javascripts/batch_comments/components/preview_item.vue @@ -1,5 +1,5 @@ <script> -import { mapActions, mapGetters } from 'vuex'; +import { mapGetters } from 'vuex'; import { GlSprintf, GlIcon } from '@gitlab/ui'; import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; import { sprintf, __ } from '~/locale'; @@ -78,7 +78,6 @@ export default { }, }, methods: { - ...mapActions('batchComments', ['scrollToDraft']), getLineClasses(lineNumber) { return getLineClasses(lineNumber); }, @@ -88,17 +87,7 @@ export default { </script> <template> - <button - type="button" - class="review-preview-item menu-item" - :class="[ - componentClasses, - { - 'is-last': isLast, - }, - ]" - @click="scrollToDraft(draft)" - > + <span> <span class="review-preview-item-header"> <gl-icon class="flex-shrink-0" :name="iconName" /> <span @@ -139,5 +128,5 @@ export default { > <gl-icon class="gl-mr-3" name="status_success" /> {{ resolvedStatusMessage }} </span> - </button> + </span> </template> diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue index 0c79e185f06..ecced36771e 100644 --- a/app/assets/javascripts/batch_comments/components/publish_button.vue +++ b/app/assets/javascripts/batch_comments/components/publish_button.vue @@ -1,7 +1,6 @@ <script> import { mapActions, mapState } from 'vuex'; import { GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; import DraftsCount from './drafts_count.vue'; export default { @@ -15,11 +14,6 @@ export default { required: false, default: false, }, - label: { - type: String, - required: false, - default: __('Finish review'), - }, category: { type: String, required: false, @@ -30,22 +24,14 @@ export default { required: false, default: 'success', }, - shouldPublish: { - type: Boolean, - required: true, - }, }, computed: { ...mapState('batchComments', ['isPublishing']), }, methods: { - ...mapActions('batchComments', ['publishReview', 'toggleReviewDropdown']), + ...mapActions('batchComments', ['publishReview']), onClick() { - if (this.shouldPublish) { - this.publishReview(); - } else { - this.toggleReviewDropdown(); - } + this.publishReview(); }, }, }; @@ -59,7 +45,7 @@ export default { :variant="variant" @click="onClick" > - {{ label }} + {{ __('Submit review') }} <drafts-count v-if="showCount" /> </gl-button> </template> diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue index e51888eabc1..035d6f4e0ab 100644 --- a/app/assets/javascripts/batch_comments/components/review_bar.vue +++ b/app/assets/javascripts/batch_comments/components/review_bar.vue @@ -1,22 +1,15 @@ <script> -/* eslint-disable vue/no-v-html */ -import { mapActions, mapState, mapGetters } from 'vuex'; -import { GlModal, GlModalDirective, GlButton } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; +import { mapActions, mapGetters } from 'vuex'; import PreviewDropdown from './preview_dropdown.vue'; +import PublishButton from './publish_button.vue'; export default { components: { - GlButton, - GlModal, PreviewDropdown, - }, - directives: { - 'gl-modal': GlModalDirective, + PublishButton, }, computed: { ...mapGetters(['isNotesFetched']), - ...mapState('batchComments', ['isDiscarding']), ...mapGetters('batchComments', ['draftsCount']), }, watch: { @@ -27,45 +20,17 @@ export default { }, }, methods: { - ...mapActions('batchComments', ['discardReview', 'expandAllDiscussions']), + ...mapActions('batchComments', ['expandAllDiscussions']), }, - modalId: 'discard-draft-review', - text: sprintf( - s__( - `BatchComments|You're about to discard your review which will delete all of your pending comments. - The deleted comments %{strong_start}cannot%{strong_end} be restored.`, - ), - { - strong_start: '<strong>', - strong_end: '</strong>', - }, - false, - ), }; </script> <template> <div v-show="draftsCount > 0"> <nav class="review-bar-component"> - <div class="review-bar-content qa-review-bar"> + <div class="review-bar-content qa-review-bar d-flex gl-justify-content-end"> <preview-dropdown /> - <gl-button - v-gl-modal="$options.modalId" - :loading="isDiscarding" - class="qa-discard-review float-right" - > - {{ __('Discard review') }} - </gl-button> + <publish-button class="gl-ml-3" show-count /> </div> </nav> - <gl-modal - :title="s__('BatchComments|Discard review?')" - :ok-title="s__('BatchComments|Delete all pending comments')" - :modal-id="$options.modalId" - title-tag="h4" - ok-variant="danger qa-modal-delete-pending-comments" - @ok="discardReview" - > - <p v-html="$options.text"></p> - </gl-modal> </div> </template> diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js index d9b92113103..ebd821125fb 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -75,15 +75,6 @@ export const updateDiscussionsAfterPublish = ({ dispatch, getters, rootGetters } }), ); -export const discardReview = ({ commit, getters }) => { - commit(types.REQUEST_DISCARD_REVIEW); - - return service - .discard(getters.getNotesData.draftsDiscardPath) - .then(() => commit(types.RECEIVE_DISCARD_REVIEW_SUCCESS)) - .catch(() => commit(types.RECEIVE_DISCARD_REVIEW_ERROR)); -}; - export const updateDraft = ( { commit, getters }, { note, noteText, resolveDiscussion, position, callback }, @@ -108,8 +99,6 @@ export const scrollToDraft = ({ dispatch, rootGetters }, draft) => { const draftID = `note_${draft.id}`; const el = document.querySelector(`#${tabEl} #${draftID}`); - dispatch('closeReviewDropdown'); - window.location.hash = draftID; if (window.mrTabs.currentAction !== tab) { @@ -125,17 +114,6 @@ export const scrollToDraft = ({ dispatch, rootGetters }, draft) => { } }; -export const toggleReviewDropdown = ({ dispatch, state }) => { - if (state.showPreviewDropdown) { - dispatch('closeReviewDropdown'); - } else { - dispatch('openReviewDropdown'); - } -}; - -export const openReviewDropdown = ({ commit }) => commit(types.OPEN_REVIEW_DROPDOWN); -export const closeReviewDropdown = ({ commit }) => commit(types.CLOSE_REVIEW_DROPDOWN); - export const expandAllDiscussions = ({ dispatch, state }) => state.drafts .filter(draft => draft.discussion_id) diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js index c8f0658c21c..df523a692d3 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js @@ -11,13 +11,6 @@ export const REQUEST_PUBLISH_REVIEW = 'REQUEST_PUBLISH_REVIEW'; export const RECEIVE_PUBLISH_REVIEW_SUCCESS = 'RECEIVE_PUBLISH_REVIEW_SUCCESS'; export const RECEIVE_PUBLISH_REVIEW_ERROR = 'RECEIVE_PUBLISH_REVIEW_ERROR'; -export const REQUEST_DISCARD_REVIEW = 'REQUEST_DISCARD_REVIEW'; -export const RECEIVE_DISCARD_REVIEW_SUCCESS = 'RECEIVE_DISCARD_REVIEW_SUCCESS'; -export const RECEIVE_DISCARD_REVIEW_ERROR = 'RECEIVE_DISCARD_REVIEW_ERROR'; - export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS'; -export const OPEN_REVIEW_DROPDOWN = 'OPEN_REVIEW_DROPDOWN'; -export const CLOSE_REVIEW_DROPDOWN = 'CLOSE_REVIEW_DROPDOWN'; - export const TOGGLE_RESOLVE_DISCUSSION = 'TOGGLE_RESOLVE_DISCUSSION'; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js index 81ceef7b160..731f4b6d12a 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js @@ -43,16 +43,6 @@ export default { [types.RECEIVE_PUBLISH_REVIEW_ERROR](state) { state.isPublishing = false; }, - [types.REQUEST_DISCARD_REVIEW](state) { - state.isDiscarding = true; - }, - [types.RECEIVE_DISCARD_REVIEW_SUCCESS](state) { - state.isDiscarding = false; - state.drafts = []; - }, - [types.RECEIVE_DISCARD_REVIEW_ERROR](state) { - state.isDiscarding = false; - }, [types.RECEIVE_DRAFT_UPDATE_SUCCESS](state, data) { const index = state.drafts.findIndex(draft => draft.id === data.id); @@ -60,12 +50,6 @@ export default { state.drafts.splice(index, 1, processDraft(data)); } }, - [types.OPEN_REVIEW_DROPDOWN](state) { - state.showPreviewDropdown = true; - }, - [types.CLOSE_REVIEW_DROPDOWN](state) { - state.showPreviewDropdown = false; - }, [types.TOGGLE_RESOLVE_DISCUSSION](state, draftId) { state.drafts = state.drafts.map(draft => { if (draft.id === draftId) { diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js index 80c710deab0..6b97fc242c8 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js @@ -4,6 +4,4 @@ export default () => ({ drafts: [], isPublishing: false, currentlyPublishingDrafts: [], - isDiscarding: false, - showPreviewDropdown: false, }); diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js index d61797b7ae4..3e9d77cdf6b 100644 --- a/app/assets/javascripts/behaviors/autosize.js +++ b/app/assets/javascripts/behaviors/autosize.js @@ -1,5 +1,5 @@ import Autosize from 'autosize'; -import { waitForCSSLoaded } from '../helpers/startup_css_helper'; +import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; document.addEventListener('DOMContentLoaded', () => { waitForCSSLoaded(() => { diff --git a/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js index d9164f6204a..c4af34b848b 100644 --- a/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js +++ b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js @@ -8,7 +8,6 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; * @sentrify */ export default () => { - const $sidebarGutterToggle = $('.js-sidebar-toggle'); let bootstrapBreakpoint = bp.getBreakpointSize(); $(window).on('resize.app', () => { @@ -19,8 +18,13 @@ export default () => { const breakpointSizes = ['md', 'sm', 'xs']; if (breakpointSizes.includes(bootstrapBreakpoint)) { - const $gutterIcon = $sidebarGutterToggle.find('i'); - if ($gutterIcon.hasClass('fa-angle-double-right')) { + const $toggleContainer = $('.js-sidebar-toggle-container'); + const isExpanded = $toggleContainer.data('is-expanded'); + const $expandIcon = $('.js-sidebar-expand'); + + if (isExpanded) { + const $sidebarGutterToggle = $expandIcon.closest('.js-sidebar-toggle'); + $sidebarGutterToggle.trigger('click'); } @@ -28,11 +32,12 @@ export default () => { // 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(); + if ( + sidebarGutterVueToggleEl && + !sidebarGutterVueToggleEl.classList.contains('js-sidebar-collapsed') + ) { + if (sidebarGutterVueToggleEl) { + sidebarGutterVueToggleEl.click(); } } } diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js index 48bcba7bcca..430a8c38387 100644 --- a/app/assets/javascripts/behaviors/copy_to_clipboard.js +++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js @@ -1,19 +1,24 @@ import $ from 'jquery'; import Clipboard from 'clipboard'; import { sprintf, __ } from '~/locale'; +import { fixTitle, show } from '~/tooltips'; function showTooltip(target, title) { - const $target = $(target); - const originalTitle = $target.data('originalTitle'); + const { originalTitle } = target.dataset; + const hideTooltip = () => { + target.removeEventListener('mouseout', hideTooltip); + setTimeout(() => { + target.setAttribute('title', originalTitle); + fixTitle(target); + }, 100); + }; - if (!$target.data('hideTooltip')) { - $target - .attr('title', title) - .tooltip('_fixTitle') - .tooltip('show') - .attr('title', originalTitle) - .tooltip('_fixTitle'); - } + target.setAttribute('title', title); + + fixTitle(target); + show(target); + + target.addEventListener('mouseout', hideTooltip); } function genericSuccess(e) { diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index bcf732e9522..16373b523b2 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -3,9 +3,7 @@ import isEmojiUnicodeSupported from '../emoji/support'; import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji'; class GlEmoji extends HTMLElement { - constructor() { - super(); - + connectedCallback() { this.initialize(); } initialize() { diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index fd12c282b62..613309a1c5a 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -13,6 +13,9 @@ import './toggler_behavior'; import './preview_markdown'; import initCollapseSidebarOnWindowResize from './collapse_sidebar_on_window_resize'; import initSelect2Dropdowns from './select2'; +import { loadStartupCSS } from './load_startup_css'; + +loadStartupCSS(); installGlEmojiElement(); diff --git a/app/assets/javascripts/behaviors/load_startup_css.js b/app/assets/javascripts/behaviors/load_startup_css.js new file mode 100644 index 00000000000..1d7bf716475 --- /dev/null +++ b/app/assets/javascripts/behaviors/load_startup_css.js @@ -0,0 +1,15 @@ +export const loadStartupCSS = () => { + // We need to fallback to dispatching `load` in case our event listener was added too late + // or the browser environment doesn't load media=print. + // Do this on `window.load` so that the default deferred behavior takes precedence. + // https://gitlab.com/gitlab-org/gitlab/-/issues/239357 + window.addEventListener( + 'load', + () => { + document + .querySelectorAll('link[media=print]') + .forEach(x => x.dispatchEvent(new Event('load'))); + }, + { once: true }, + ); +}; diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js index d712c90242c..83f2ca0bdc2 100644 --- a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js +++ b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js @@ -14,7 +14,6 @@ export default function initGFMInput($els) { milestones: enableGFM, mergeRequests: enableGFM, labels: enableGFM, - vulnerabilities: enableGFM, }); }); } diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js new file mode 100644 index 00000000000..bbcc40ab9fe --- /dev/null +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -0,0 +1,96 @@ +import { flatten } from 'lodash'; +import { s__ } from '~/locale'; +import AccessorUtilities from '~/lib/utils/accessor'; +import { shouldDisableShortcuts } from './shortcuts_toggle'; + +export const LOCAL_STORAGE_KEY = 'gl-keyboard-shortcuts-customizations'; + +let parsedCustomizations = {}; +const localStorageIsSafe = AccessorUtilities.isLocalStorageAccessSafe(); + +if (localStorageIsSafe) { + try { + parsedCustomizations = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}'); + } catch (e) { + /* do nothing */ + } +} + +/** + * A map of command => keys of all keyboard shortcuts + * that have been customized by the user. + * + * @example + * { "globalShortcuts.togglePerformanceBar": ["p e r f"] } + * + * @type { Object.<string, string[]> } + */ +export const customizations = parsedCustomizations; + +// All available commands +export const TOGGLE_PERFORMANCE_BAR = 'globalShortcuts.togglePerformanceBar'; + +/** All keybindings, grouped and ordered with descriptions */ +export const keybindingGroups = [ + { + groupId: 'globalShortcuts', + name: s__('KeyboardShortcuts|Global Shortcuts'), + keybindings: [ + { + description: s__('KeyboardShortcuts|Toggle the Performance Bar'), + command: TOGGLE_PERFORMANCE_BAR, + // eslint-disable-next-line @gitlab/require-i18n-strings + defaultKeys: ['p b'], + }, + ], + }, +] + + // For each keybinding object, add a `customKeys` property populated with the + // user's custom keybindings (if the command has been customized). + // `customKeys` will be `undefined` if the command hasn't been customized. + .map(group => { + return { + ...group, + keybindings: group.keybindings.map(binding => ({ + ...binding, + customKeys: customizations[binding.command], + })), + }; + }); + +/** + * A simple map of command => keys. All user customizations are included in this map. + * This mapping is used to simplify `keysFor` below. + * + * @example + * { "globalShortcuts.togglePerformanceBar": ["p e r f"] } + */ +const commandToKeys = flatten(keybindingGroups.map(group => group.keybindings)).reduce( + (acc, binding) => { + acc[binding.command] = binding.customKeys || binding.defaultKeys; + return acc; + }, + {}, +); + +/** + * Gets keyboard shortcuts associated with a command + * + * @param {string} command The command string. All command + * strings are available as imports from this file. + * + * @returns {string[]} An array of keyboard shortcut strings bound to the command + * + * @example + * import { keysFor, TOGGLE_PERFORMANCE_BAR } from '~/behaviors/shortcuts/keybindings' + * + * Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), handler); + */ +export const keysFor = command => { + if (shouldDisableShortcuts()) { + return []; + } + + return commandToKeys[command]; +}; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index 8a8b61a57cd..a53150f8d61 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -9,6 +9,7 @@ import axios from '../../lib/utils/axios_utils'; import { refreshCurrentPage, visitUrl } from '../../lib/utils/url_utility'; import findAndFollowLink from '../../lib/utils/navigation_utility'; import { parseBoolean, getCspNonceValue } from '~/lib/utils/common_utils'; +import { keysFor, TOGGLE_PERFORMANCE_BAR } from './keybindings'; const defaultStopCallback = Mousetrap.prototype.stopCallback; Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) { @@ -70,7 +71,7 @@ export default class Shortcuts { Mousetrap.bind('s', Shortcuts.focusSearch); Mousetrap.bind('/', Shortcuts.focusSearch); Mousetrap.bind('f', this.focusFilter.bind(this)); - Mousetrap.bind('p b', Shortcuts.onTogglePerfBar); + Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), Shortcuts.onTogglePerfBar); const findFileURL = document.body.dataset.findFile; @@ -117,9 +118,9 @@ export default class Shortcuts { e.preventDefault(); const performanceBarCookieName = 'perf_bar_enabled'; if (parseBoolean(Cookies.get(performanceBarCookieName))) { - Cookies.set(performanceBarCookieName, 'false', { path: '/' }); + Cookies.set(performanceBarCookieName, 'false', { expires: 365, path: '/' }); } else { - Cookies.set(performanceBarCookieName, 'true', { path: '/' }); + Cookies.set(performanceBarCookieName, 'true', { expires: 365, path: '/' }); } refreshCurrentPage(); } diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue index 6293f3bed1c..a013d637c1d 100644 --- a/app/assets/javascripts/blob/components/blob_edit_content.vue +++ b/app/assets/javascripts/blob/components/blob_edit_content.vue @@ -1,12 +1,9 @@ <script> import { debounce } from 'lodash'; import { initEditorLite } from '~/blob/utils'; -import { - SNIPPET_MARK_BLOBS_CONTENT, - SNIPPET_MARK_EDIT_APP_START, - SNIPPET_MEASURE_BLOBS_CONTENT, - SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP, -} from '~/performance_constants'; +import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance_constants'; + +import eventHub from './eventhub'; export default { props: { @@ -48,13 +45,7 @@ export default { this.editor.onDidChangeModelContent(debounce(this.onFileChange.bind(this), 250)); - window.requestAnimationFrame(() => { - if (!performance.getEntriesByName(SNIPPET_MARK_BLOBS_CONTENT).length) { - performance.mark(SNIPPET_MARK_BLOBS_CONTENT); - performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT); - performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP, SNIPPET_MARK_EDIT_APP_START); - } - }); + eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT); }, beforeDestroy() { this.editor.dispose(); diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue index 601b694db87..f99ecba2324 100644 --- a/app/assets/javascripts/blob/components/blob_header_filepath.vue +++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue @@ -43,6 +43,7 @@ export default { :text="blob.path" :gfm="gfmCopyText" :title="__('Copy file path')" + category="tertiary" css-class="btn-clipboard btn-transparent lh-100 position-static" /> </div> diff --git a/app/assets/javascripts/blob/components/eventhub.js b/app/assets/javascripts/blob/components/eventhub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/blob/components/eventhub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue index 411241b72d5..1412e49836d 100644 --- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue +++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue @@ -1,8 +1,7 @@ <script> import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; import Cookies from 'js-cookie'; -import { sprintf, s__, __ } from '~/locale'; -import { glEmojiTag } from '~/emoji'; +import { s__ } from '~/locale'; import Tracking from '~/tracking'; const trackingMixin = Tracking.mixin(); @@ -12,21 +11,6 @@ export default { 'https://about.gitlab.com/blog/2018/01/22/a-beginners-guide-to-continuous-integration/', exampleLink: 'https://docs.gitlab.com/ee/ci/examples/', codeQualityLink: 'https://docs.gitlab.com/ee/user/project/merge_requests/code_quality.html', - bodyMessage: s__( - `MR widget|The pipeline will test your code on every commit. A %{codeQualityLinkStart}code quality report%{codeQualityLinkEnd} will appear in your merge requests to warn you about potential code degradations.`, - ), - helpMessage: s__( - `MR widget|Take a look at our %{beginnerLinkStart}Beginner's Guide to Continuous Integration%{beginnerLinkEnd} and our %{exampleLinkStart}examples of GitLab CI/CD%{exampleLinkEnd} to learn more.`, - ), - pipelinesButton: s__('MR widget|See your pipeline in action'), - mergeRequestButton: s__('MR widget|Back to the Merge request'), - modalTitle: sprintf( - __("That's it, well done!%{celebrate}"), - { - celebrate: glEmojiTag('tada'), - }, - false, - ), goToTrackValuePipelines: 10, goToTrackValueMergeRequest: 20, trackEvent: 'click_button', @@ -78,6 +62,17 @@ export default { return ''; }, }, + i18n: { + modalTitle: s__("That's it, well done!"), + pipelinesButton: s__('MR widget|See your pipeline in action'), + mergeRequestButton: s__('MR widget|Back to the Merge request'), + bodyMessage: s__( + `MR widget|The pipeline will test your code on every commit. A %{codeQualityLinkStart}code quality report%{codeQualityLinkEnd} will appear in your merge requests to warn you about potential code degradations.`, + ), + helpMessage: s__( + `MR widget|Take a look at our %{beginnerLinkStart}Beginner's Guide to Continuous Integration%{beginnerLinkEnd} and our %{exampleLinkStart}examples of GitLab CI/CD%{exampleLinkEnd} to learn more.`, + ), + }, mounted() { this.track(); this.disableModalFromRenderingAgain(); @@ -90,14 +85,13 @@ export default { }; </script> <template> - <gl-modal - visible - size="sm" - :title="$options.modalTitle" - modal-id="success-pipeline-modal-id-not-used" - > + <gl-modal visible size="sm" modal-id="success-pipeline-modal-id-not-used"> + <template #modal-title> + {{ $options.i18n.modalTitle }} + <gl-emoji class="gl-vertical-align-baseline font-size-inherit gl-mr-1" data-name="tada" /> + </template> <p> - <gl-sprintf :message="$options.bodyMessage"> + <gl-sprintf :message="$options.i18n.bodyMessage"> <template #codeQualityLink="{content}"> <gl-link :href="$options.codeQualityLink" target="_blank" class="font-size-inherit">{{ content @@ -105,7 +99,7 @@ export default { </template> </gl-sprintf> </p> - <gl-sprintf :message="$options.helpMessage"> + <gl-sprintf :message="$options.i18n.helpMessage"> <template #beginnerLink="{content}"> <gl-link :href="$options.beginnerLink" target="_blank"> {{ content }} @@ -127,7 +121,7 @@ export default { :data-track-event="$options.trackEvent" :data-track-label="trackLabel" > - {{ $options.mergeRequestButton }} + {{ $options.i18n.mergeRequestButton }} </gl-button> <gl-button ref="goToPipelines" @@ -138,7 +132,7 @@ export default { :data-track-event="$options.trackEvent" :data-track-label="trackLabel" > - {{ $options.pipelinesButton }} + {{ $options.i18n.pipelinesButton }} </gl-button> </template> </gl-modal> diff --git a/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue b/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue deleted file mode 100644 index 1308ca53e74..00000000000 --- a/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue +++ /dev/null @@ -1,50 +0,0 @@ -<script> -import { GlAlert, GlButton } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; - -export default { - components: { - GlAlert, - GlButton, - }, - props: { - dismissEndpoint: { - type: String, - required: true, - }, - featureId: { - type: String, - required: true, - }, - editPath: { - type: String, - required: true, - }, - }, - data() { - return { - showAlert: true, - }; - }, - methods: { - dismissAlert() { - this.showAlert = false; - - return axios.post(this.dismissEndpoint, { - feature_name: this.featureId, - }); - }, - }, -}; -</script> - -<template> - <gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissAlert"> - {{ __('The Web IDE offers advanced syntax highlighting capabilities and more.') }} - <div class="gl-mt-5"> - <gl-button :href="editPath" category="primary" variant="info">{{ - __('Open Web IDE') - }}</gl-button> - </div> - </gl-alert> -</template> diff --git a/app/assets/javascripts/blob/suggest_web_ide_ci/index.js b/app/assets/javascripts/blob/suggest_web_ide_ci/index.js deleted file mode 100644 index eadf3cd6216..00000000000 --- a/app/assets/javascripts/blob/suggest_web_ide_ci/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import Vue from 'vue'; -import WebIdeAlert from './components/web_ide_alert.vue'; - -export default el => { - const { dismissEndpoint, featureId, editPath } = el.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el, - render(createElement) { - return createElement(WebIdeAlert, { - props: { - dismissEndpoint, - featureId, - editPath, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 05ee8e49eb1..aa76364c466 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -5,6 +5,7 @@ import { handleLocationHash } from '../../lib/utils/common_utils'; import axios from '../../lib/utils/axios_utils'; import eventHub from '../../notes/event_hub'; import { __ } from '~/locale'; +import { fixTitle } from '~/tooltips'; const loadRichBlobViewer = type => { switch (type) { @@ -124,7 +125,7 @@ export default class BlobViewer { this.copySourceBtn.classList.add('disabled'); } - $(this.copySourceBtn).tooltip('_fixTitle'); + fixTitle($(this.copySourceBtn)); } switchToViewer(name) { @@ -179,9 +180,7 @@ export default class BlobViewer { viewer.innerHTML = data.html; viewer.setAttribute('data-loaded', 'true'); - if (window.gon?.features?.codeNavigation) { - eventHub.$emit('showBlobInteractionZones', viewer.dataset.path); - } + eventHub.$emit('showBlobInteractionZones', viewer.dataset.path); return viewer; }); diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index c9972f0b43c..2d426ee663a 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -2,19 +2,17 @@ import $ from 'jquery'; import NewCommitForm from '../new_commit_form'; -import EditBlob from './edit_blob'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import BlobFileDropzone from '../blob/blob_file_dropzone'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils'; import Tracking from '~/tracking'; -import initWebIdeAlert from '~/blob/suggest_web_ide_ci'; export default () => { const editBlobForm = $('.js-edit-blob-form'); const uploadBlobForm = $('.js-upload-blob-form'); const deleteBlobForm = $('.js-delete-blob-form'); const suggestEl = document.querySelector('.js-suggest-gitlab-ci-yml'); - const alertEl = document.getElementById('js-suggest-web-ide-ci'); if (editBlobForm.length) { const urlRoot = editBlobForm.data('relativeUrlRoot'); @@ -26,6 +24,18 @@ export default () => { const commitButton = $('.js-commit-button'); const cancelLink = $('.btn.btn-cancel'); + import('./edit_blob') + .then(({ default: EditBlob } = {}) => { + new EditBlob({ + assetsPath: `${urlRoot}${assetsPath}`, + filePath, + currentAction, + projectId, + isMarkdown, + }); + }) + .catch(e => createFlash(e)); + cancelLink.on('click', () => { window.onbeforeunload = null; }); @@ -34,13 +44,6 @@ export default () => { window.onbeforeunload = null; }); - new EditBlob({ - assetsPath: `${urlRoot}${assetsPath}`, - filePath, - currentAction, - projectId, - isMarkdown, - }); new NewCommitForm(editBlobForm); // returning here blocks page navigation @@ -85,8 +88,4 @@ export default () => { }); } } - - if (alertEl) { - initWebIdeAlert(alertEl); - } }; diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 2a4ab4b8827..e6b0a6fc1c5 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -1,91 +1,56 @@ -/* global ace */ - import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants'; import TemplateSelectorMediator from '../blob/file_template_mediator'; -import getModeByFileExtension from '~/lib/utils/ace_utils'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; - -const monacoEnabledGlobally = window.gon.features?.monacoBlobs; +import EditorLite from '~/editor/editor_lite'; +import FileTemplateExtension from '~/editor/editor_file_template_ext'; export default class EditBlob { // The options object has: // assetsPath, filePath, currentAction, projectId, isMarkdown constructor(options) { this.options = options; - 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) { + this.configureMonacoEditor(); + + if (this.options.isMarkdown) { + import('~/editor/editor_markdown_ext') + .then(MarkdownExtension => { + this.editor.use(MarkdownExtension.default); addEditorMarkdownListeners(this.editor); - } - this.editor.focus(); - }) - .catch(() => createFlash(BLOB_EDITOR_ERROR)); + }) + .catch(() => createFlash(BLOB_EDITOR_ERROR)); + } + + this.initModePanesAndLinks(); + this.initFileSelectors(); + this.initSoftWrap(); + this.editor.focus(); } configureMonacoEditor() { - const EditorPromise = import( - /* webpackChunkName: 'monaco_editor_lite' */ '~/editor/editor_lite' - ); - const MarkdownExtensionPromise = this.options.isMarkdown - ? import('~/editor/editor_markdown_ext') - : Promise.resolve(false); - const FileTemplateExtensionPromise = import('~/editor/editor_file_template_ext'); - - return Promise.all([EditorPromise, MarkdownExtensionPromise, FileTemplateExtensionPromise]) - .then(([EditorModule, MarkdownExtension, FileTemplateExtension]) => { - 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'); - - const rootEditor = new EditorLite(); - - this.editor = rootEditor.createInstance({ - el: editorEl, - blobPath: fileNameEl.value, - blobContent: editorEl.innerText, - }); - - rootEditor.use([MarkdownExtension.default, FileTemplateExtension.default], this.editor); - - fileNameEl.addEventListener('change', () => { - this.editor.updateModelLanguage(fileNameEl.value); - }); - - form.addEventListener('submit', () => { - fileContentEl.value = this.editor.getValue(); - }); - }) - .catch(() => createFlash(BLOB_EDITOR_ERROR)); - } + 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'); - configureAceEditor() { - const { filePath, assetsPath } = this.options; - ace.config.set('modePath', `${assetsPath}/ace`); - ace.config.loadModule('ace/ext/searchbox'); - ace.config.loadModule('ace/ext/modelist'); + const rootEditor = new EditorLite(); - this.editor = ace.edit('editor'); + this.editor = rootEditor.createInstance({ + el: editorEl, + blobPath: fileNameEl.value, + blobContent: editorEl.innerText, + }); + this.editor.use(FileTemplateExtension); - // This prevents warnings re: automatic scrolling being logged - this.editor.$blockScrolling = Infinity; + fileNameEl.addEventListener('change', () => { + this.editor.updateModelLanguage(fileNameEl.value); + }); - if (filePath) { - this.editor.getSession().setMode(getModeByFileExtension(filePath)); - } + form.addEventListener('submit', () => { + fileContentEl.value = this.editor.getValue(); + }); } initFileSelectors() { @@ -137,7 +102,7 @@ export default class EditBlob { } initSoftWrap() { - this.isSoftWrapped = Boolean(this.options.monacoEnabled); + this.isSoftWrapped = true; this.$toggleButton = $('.soft-wrap-toggle'); this.$toggleButton.toggleClass('soft-wrap-active', this.isSoftWrapped); this.$toggleButton.on('click', () => this.toggleSoftWrap()); @@ -146,10 +111,6 @@ export default class EditBlob { toggleSoftWrap() { this.isSoftWrapped = !this.isSoftWrapped; this.$toggleButton.toggleClass('soft-wrap-active', this.isSoftWrapped); - if (this.options.monacoEnabled) { - this.editor.updateOptions({ wordWrap: this.isSoftWrapped ? 'on' : 'off' }); - } else { - this.editor.getSession().setUseWrapMode(this.isSoftWrapped); - } + this.editor.updateOptions({ wordWrap: this.isSoftWrapped ? 'on' : 'off' }); } } diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 5c8df94ca90..6b7b0c2e28d 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -2,11 +2,24 @@ import { sortBy } from 'lodash'; import ListIssue from 'ee_else_ce/boards/models/issue'; import { ListType } from './constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import boardsStore from '~/boards/stores/boards_store'; export function getMilestone() { return null; } +export function formatBoardLists(lists) { + const formattedLists = lists.nodes.map(list => + boardsStore.updateListPosition({ ...list, doNotFetchIssues: true }), + ); + return formattedLists.reduce((map, list) => { + return { + ...map, + [list.id]: list, + }; + }, {}); +} + export function formatIssue(issue) { return new ListIssue({ ...issue, @@ -17,9 +30,15 @@ export function formatIssue(issue) { export function formatListIssues(listIssues) { const issues = {}; + let listIssuesCount; const listData = listIssues.nodes.reduce((map, list) => { - const sortedIssues = sortBy(list.issues.nodes, 'relativePosition'); + listIssuesCount = list.issues.count; + let sortedIssues = list.issues.edges.map(issueNode => ({ + ...issueNode.node, + })); + sortedIssues = sortBy(sortedIssues, 'relativePosition'); + return { ...map, [list.id]: sortedIssues.map(i => { @@ -39,13 +58,30 @@ export function formatListIssues(listIssues) { }; }, {}); - return { listData, issues }; + return { listData, issues, listIssuesCount }; +} + +export function formatListsPageInfo(lists) { + const listData = lists.nodes.reduce((map, list) => { + return { + ...map, + [list.id]: list.issues.pageInfo, + }; + }, {}); + return listData; } export function fullBoardId(boardId) { return `gid://gitlab/Board/${boardId}`; } +export function fullLabelId(label) { + if (label.project_id !== null) { + return `gid://gitlab/ProjectLabel/${label.id}`; + } + return `gid://gitlab/GroupLabel/${label.id}`; +} + export function moveIssueListHelper(issue, fromList, toList) { if (toList.type === ListType.label) { issue.addLabel(toList.label); @@ -69,4 +105,5 @@ export default { formatIssue, formatListIssues, fullBoardId, + fullLabelId, }; diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue deleted file mode 100644 index 55e3e4a6329..00000000000 --- a/app/assets/javascripts/boards/components/board_blank_state.vue +++ /dev/null @@ -1,104 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import Cookies from 'js-cookie'; -import { __ } from '~/locale'; -import ListLabel from '~/boards/models/label'; -import boardsStore from '../stores/boards_store'; - -export default { - components: { - GlButton, - }, - data() { - return { - predefinedLabels: [ - new ListLabel({ title: __('To Do'), color: '#F0AD4E' }), - new ListLabel({ title: __('Doing'), color: '#5CB85C' }), - ], - }; - }, - methods: { - addDefaultLists() { - this.clearBlankState(); - - this.predefinedLabels.forEach((label, i) => { - boardsStore.addList({ - title: label.title, - position: i, - list_type: 'label', - label: { - title: label.title, - color: label.color, - }, - }); - }); - - const loadListIssues = listObj => { - const list = boardsStore.findList('title', listObj.title); - - if (!list) { - return null; - } - - list.id = listObj.id; - list.label.id = listObj.label.id; - return list.getIssues().catch(() => { - // TODO: handle request error - }); - }; - - // Save the labels - boardsStore - .generateDefaultLists() - .then(res => res.data) - .then(data => Promise.all(data.map(loadListIssues))) - .catch(() => { - boardsStore.removeList(undefined, 'label'); - Cookies.remove('issue_board_welcome_hidden', { - path: '', - }); - boardsStore.addBlankState(); - }); - }, - clearBlankState: boardsStore.removeBlankState.bind(boardsStore), - }, -}; -</script> - -<template> - <div class="board-blank-state p-3"> - <p> - {{ - s__('BoardBlankState|Add the following default lists to your Issue Board with one click:') - }} - </p> - <ul class="list-unstyled board-blank-state-list"> - <li v-for="(label, index) in predefinedLabels" :key="index"> - <span - :style="{ backgroundColor: label.color }" - class="label-color position-relative d-inline-block rounded" - ></span> - {{ label.title }} - </li> - </ul> - <p> - {{ - s__( - 'BoardBlankState|Starting out with the default set of lists will get you right on the way to making the most of your board.', - ) - }} - </p> - <gl-button - category="secondary" - variant="success" - block="block" - class="gl-mb-0" - @click.stop="addDefaultLists" - > - {{ s__('BoardBlankState|Add default lists') }} - </gl-button> - <gl-button category="secondary" variant="default" block="block" @click.stop="clearBlankState"> - {{ s__("BoardBlankState|Nevermind, I'll use my own") }} - </gl-button> - </div> -</template> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 6d216911798..9295065b7b7 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -1,13 +1,12 @@ <script> import { mapGetters, mapActions } from 'vuex'; import Sortable from 'sortablejs'; -import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'; import Tooltip from '~/vue_shared/directives/tooltip'; import EmptyComponent from '~/vue_shared/components/empty_component'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import BoardBlankState from './board_blank_state.vue'; import BoardList from './board_list.vue'; +import BoardListNew from './board_list_new.vue'; import boardsStore from '../stores/boards_store'; import eventHub from '../eventhub'; import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options'; @@ -16,14 +15,13 @@ import { ListType } from '../constants'; export default { components: { BoardPromotionState: EmptyComponent, - BoardBlankState, BoardListHeader, - BoardList, + BoardList: gon.features?.graphqlBoardLists ? BoardListNew : BoardList, }, directives: { Tooltip, }, - mixins: [isWipLimitsOn, glFeatureFlagMixin()], + mixins: [glFeatureFlagMixin()], props: { list: { type: Object, @@ -42,7 +40,7 @@ export default { }, inject: { boardId: { - type: String, + default: '', }, }, data() { @@ -54,7 +52,7 @@ export default { computed: { ...mapGetters(['getIssues']), showBoardListAndBoardInfo() { - return this.list.type !== ListType.blank && this.list.type !== ListType.promotion; + return this.list.type !== ListType.promotion; }, uniqueKey() { // eslint-disable-next-line @gitlab/require-i18n-strings @@ -74,7 +72,7 @@ export default { filter: { handler() { if (this.shouldFetchIssues) { - this.fetchIssuesForList(this.list.id); + this.fetchIssuesForList({ listId: this.list.id }); } else { this.list.page = 1; this.list.getIssues(true).catch(() => { @@ -87,7 +85,7 @@ export default { }, mounted() { if (this.shouldFetchIssues) { - this.fetchIssuesForList(this.list.id); + this.fetchIssuesForList({ listId: this.list.id }); } const instance = this; @@ -146,9 +144,7 @@ export default { :disabled="disabled" :issues="listIssues" :list="list" - :loading="list.loading" /> - <board-blank-state v-if="canAdminList && list.id === 'blank'" /> <!-- Will be only available in EE --> <board-promotion-state v-if="list.id === 'promotion'" /> diff --git a/app/assets/javascripts/boards/components/board_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue new file mode 100644 index 00000000000..ad3d653b905 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_configuration_options.vue @@ -0,0 +1,65 @@ +<script> +import { GlFormCheckbox } from '@gitlab/ui'; + +export default { + components: { + GlFormCheckbox, + }, + props: { + currentBoard: { + type: Object, + required: true, + }, + board: { + type: Object, + required: true, + }, + isNewForm: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + const { hide_backlog_list: hideBacklogList, hide_closed_list: hideClosedList } = this.isNewForm + ? this.board + : this.currentBoard; + + return { + hideClosedList, + hideBacklogList, + }; + }, + methods: { + changeClosedList(checked) { + this.board.hideClosedList = !checked; + }, + changeBacklogList(checked) { + this.board.hideBacklogList = !checked; + }, + }, +}; +</script> + +<template> + <div class="append-bottom-20"> + <label class="form-section-title label-bold" for="board-new-name"> + {{ __('List options') }} + </label> + <p class="text-secondary gl-mb-3"> + {{ __('Configure which lists are shown for anyone who visits this board') }} + </p> + <gl-form-checkbox + :checked="!hideBacklogList" + data-testid="backlog-list-checkbox" + @change="changeBacklogList" + >{{ __('Show the Open list') }} + </gl-form-checkbox> + <gl-form-checkbox + :checked="!hideClosedList" + data-testid="closed-list-checkbox" + @change="changeClosedList" + >{{ __('Show the Closed list') }} + </gl-form-checkbox> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index c7b3da0e672..2515f471379 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -1,5 +1,6 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; +import { sortBy } from 'lodash'; import BoardColumn from 'ee_else_ce/boards/components/board_column.vue'; import { GlAlert } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -30,7 +31,9 @@ export default { ...mapState(['boardLists', 'error']), ...mapGetters(['isSwimlanesOn']), boardListsToUse() { - return this.glFeatures.graphqlBoardLists ? this.boardLists : this.lists; + const lists = + this.glFeatures.graphqlBoardLists || this.isSwimlanesOn ? this.boardLists : this.lists; + return sortBy([...Object.values(lists)], 'position'); }, }, mounted() { @@ -68,7 +71,7 @@ export default { <template v-else> <epics-swimlanes ref="swimlanes" - :lists="boardLists" + :lists="boardListsToUse" :can-admin-list="canAdminList" :disabled="disabled" /> diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js deleted file mode 100644 index b74234a2e3c..00000000000 --- a/app/assets/javascripts/boards/components/board_delete.js +++ /dev/null @@ -1,30 +0,0 @@ -import $ from 'jquery'; -import Vue from 'vue'; -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default Vue.extend({ - components: { - GlButton, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - list: { - type: Object, - default: () => ({}), - required: false, - }, - }, - methods: { - deleteBoard() { - $(this.$el).tooltip('hide'); - - // eslint-disable-next-line no-alert - if (window.confirm(__('Are you sure you want to delete this list?'))) { - this.list.destroy(); - } - }, - }, -}); diff --git a/app/assets/javascripts/boards/components/board_extra_actions.vue b/app/assets/javascripts/boards/components/board_extra_actions.vue new file mode 100644 index 00000000000..b802ccc7882 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_extra_actions.vue @@ -0,0 +1,57 @@ +<script> +import { GlTooltip, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'BoardExtraActions', + components: { + GlTooltip, + GlButton, + }, + props: { + canAdminList: { + type: Boolean, + required: true, + }, + disabled: { + type: Boolean, + required: true, + }, + openModal: { + type: Function, + required: true, + }, + }, + computed: { + tooltipTitle() { + if (this.disabled) { + return __('Please add a list to your board first'); + } + + return ''; + }, + }, +}; +</script> + +<template> + <div class="board-extra-actions"> + <span ref="addIssuesButtonTooltip" class="gl-ml-3"> + <gl-button + v-if="canAdminList" + type="button" + data-placement="bottom" + data-track-event="click_button" + data-track-label="board_add_issues" + :disabled="disabled" + :aria-disabled="disabled" + @click="openModal" + > + {{ __('Add issues') }} + </gl-button> + </span> + <gl-tooltip v-if="disabled" :target="() => $refs.addIssuesButtonTooltip" placement="bottom"> + {{ tooltipTitle }} + </gl-tooltip> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 385dd5fdc71..793c594cf16 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -5,6 +5,8 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import { visitUrl } from '~/lib/utils/url_utility'; import boardsStore from '~/boards/stores/boards_store'; +import BoardConfigurationOptions from './board_configuration_options.vue'; + const boardDefaults = { id: false, name: '', @@ -13,12 +15,15 @@ const boardDefaults = { assignee: {}, assignee_id: undefined, weight: null, + hide_backlog_list: false, + hide_closed_list: false, }; export default { components: { BoardScope: () => import('ee_component/boards/components/board_scope.vue'), DeprecatedModal, + BoardConfigurationOptions, }, props: { canAdminBoard: { @@ -140,7 +145,17 @@ export default { } else { boardsStore .createBoard(this.board) - .then(resp => resp.data) + .then(resp => { + // This handles 2 use cases + // - In create call we only get one parameter, the new board + // - In update call, due to Promise.all, we get REST response in + // array index 0 + + if (Array.isArray(resp)) { + return resp[0].data; + } + return resp.data ? resp.data : resp; + }) .then(data => { visitUrl(data.board_path); }) @@ -182,7 +197,7 @@ export default { <form v-else class="js-board-config-modal" @submit.prevent> <div v-if="!readonly" class="append-bottom-20"> <label class="form-section-title label-bold" for="board-new-name">{{ - __('Board name') + __('Title') }}</label> <input id="board-new-name" @@ -196,6 +211,12 @@ export default { /> </div> + <board-configuration-options + :is-new-form="isNewForm" + :board="board" + :current-board="currentBoard" + /> + <board-scope v-if="scopedIssueBoardFeatureEnabled" :collapse-scope="isNewForm" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 25f8ffca633..d01df44e7e4 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -14,6 +14,8 @@ import { sortableEnd, } from '../mixins/sortable_default_options'; +// This component is being replaced in favor of './board_list_new.vue' for GraphQL boards + if (gon.features && gon.features.multiSelectBoard) { Sortable.mount(new MultiDrag()); } @@ -39,10 +41,6 @@ export default { type: Array, required: true, }, - loading: { - type: Boolean, - required: true, - }, }, data() { return { @@ -62,6 +60,9 @@ export default { issuesSizeExceedsMax() { return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount; }, + loading() { + return this.list.loading; + }, }, watch: { filters: { @@ -72,7 +73,6 @@ export default { deep: true, }, issues() { - if (this.glFeatures.graphqlBoardLists) return; this.$nextTick(() => { if ( this.scrollHeight() <= this.listHeight() && @@ -98,6 +98,8 @@ export default { eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); }, mounted() { + // TODO: Use Draggable in ./board_list_new.vue to drag & drop issue + // https://gitlab.com/gitlab-org/gitlab/-/issues/218164 const multiSelectOpts = {}; if (gon.features && gon.features.multiSelectBoard) { multiSelectOpts.multiDrag = true; @@ -403,8 +405,6 @@ export default { this.showIssueForm = !this.showIssueForm; }, onScroll() { - if (this.glFeatures.graphqlBoardLists) return; - if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) { this.loadNextPage(); } diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 361fe252afb..bb9a1b79d91 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -1,5 +1,5 @@ <script> -import { mapActions } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import { GlButton, GlButtonGroup, @@ -9,20 +9,18 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; -import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; import { n__, s__ } from '~/locale'; import AccessorUtilities from '../../lib/utils/accessor'; -import BoardDelete from './board_delete'; import IssueCount from './issue_count.vue'; import boardsStore from '../stores/boards_store'; import eventHub from '../eventhub'; -import { ListType } from '../constants'; +import sidebarEventHub from '~/sidebar/event_hub'; +import { inactiveId, LIST, ListType } from '../constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { - BoardDelete, GlButtonGroup, GlButton, GlLabel, @@ -34,7 +32,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [isWipLimitsOn, glFeatureFlagMixin()], + mixins: [glFeatureFlagMixin()], props: { list: { type: Object, @@ -45,11 +43,6 @@ export default { type: Boolean, required: true, }, - canAdminList: { - type: Boolean, - required: false, - default: false, - }, isSwimlanesHeader: { type: Boolean, required: false, @@ -58,7 +51,7 @@ export default { }, inject: { boardId: { - type: String, + default: '', }, }, data() { @@ -67,6 +60,7 @@ export default { }; }, computed: { + ...mapState(['activeId']), isLoggedIn() { return Boolean(gon.current_user_id); }, @@ -114,10 +108,7 @@ export default { }, isSettingsShown() { return ( - this.listType !== ListType.backlog && - this.showListHeaderButton && - this.list.isExpanded && - this.isWipLimitsOn + this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded ); }, showBoardListAndBoardInfo() { @@ -135,7 +126,14 @@ export default { }, }, methods: { - ...mapActions(['updateList']), + ...mapActions(['updateList', 'setActiveId']), + openSidebarSettings() { + if (this.activeId === inactiveId) { + sidebarEventHub.$emit('sidebar.closeAll'); + } + + this.setActiveId({ id: this.list.id, sidebarType: LIST }); + }, showScopedLabels(label) { return boardsStore.scopedLabels.enabled && isScopedLabel(label); }, @@ -176,7 +174,6 @@ export default { <header :class="{ 'has-border': list.label && list.label.color, - 'gl-relative': list.isExpanded, 'gl-h-full': !list.isExpanded, 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader, }" @@ -279,22 +276,6 @@ export default { </div> </gl-tooltip> - <board-delete - v-if="canAdminList && !list.preset && list.id" - :list="list" - inline-template="true" - > - <gl-button - 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" - :title="__('Delete list')" - icon="remove" - size="small" - @click.stop="deleteBoard" - /> - </board-delete> <div v-if="showBoardListAndBoardInfo" class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary" diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue new file mode 100644 index 00000000000..0a495d05122 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_list_new.vue @@ -0,0 +1,166 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import BoardNewIssue from './board_new_issue.vue'; +import BoardCard from './board_card.vue'; +import eventHub from '../eventhub'; +import boardsStore from '../stores/boards_store'; +import { sprintf, __ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + name: 'BoardList', + components: { + BoardCard, + BoardNewIssue, + GlLoadingIcon, + }, + mixins: [glFeatureFlagMixin()], + props: { + disabled: { + type: Boolean, + required: true, + }, + list: { + type: Object, + required: true, + }, + issues: { + type: Array, + required: true, + }, + }, + data() { + return { + scrollOffset: 250, + filters: boardsStore.state.filters, + showCount: false, + showIssueForm: false, + }; + }, + computed: { + ...mapState(['pageInfoByListId', 'listsFlags']), + paginatedIssueText() { + return sprintf(__('Showing %{pageSize} of %{total} issues'), { + pageSize: this.issues.length, + total: this.list.issuesSize, + }); + }, + issuesSizeExceedsMax() { + return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount; + }, + hasNextPage() { + return this.pageInfoByListId[this.list.id].hasNextPage; + }, + loading() { + return this.listsFlags[this.list.id]?.isLoading; + }, + }, + watch: { + filters: { + handler() { + this.list.loadingMore = false; + this.$refs.list.scrollTop = 0; + }, + deep: true, + }, + issues() { + this.$nextTick(() => { + this.showCount = this.scrollHeight() > Math.ceil(this.listHeight()); + }); + }, + }, + created() { + eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); + }, + mounted() { + // Scroll event on list to load more + this.$refs.list.addEventListener('scroll', this.onScroll); + }, + beforeDestroy() { + eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); + this.$refs.list.removeEventListener('scroll', this.onScroll); + }, + methods: { + ...mapActions(['fetchIssuesForList']), + listHeight() { + return this.$refs.list.getBoundingClientRect().height; + }, + scrollHeight() { + return this.$refs.list.scrollHeight; + }, + scrollTop() { + return this.$refs.list.scrollTop + this.listHeight(); + }, + scrollToTop() { + this.$refs.list.scrollTop = 0; + }, + loadNextPage() { + const loadingDone = () => { + this.list.loadingMore = false; + }; + this.list.loadingMore = true; + this.fetchIssuesForList({ listId: this.list.id, fetchNext: true }) + .then(loadingDone) + .catch(loadingDone); + }, + toggleForm() { + this.showIssueForm = !this.showIssueForm; + }, + onScroll() { + window.requestAnimationFrame(() => { + if ( + !this.list.loadingMore && + this.scrollTop() > this.scrollHeight() - this.scrollOffset && + this.hasNextPage + ) { + this.loadNextPage(); + } + }); + }, + }, +}; +</script> + +<template> + <div + v-show="list.isExpanded" + class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column" + data-qa-selector="board_list_cards_area" + > + <div + v-if="loading" + class="gl-mt-4 gl-text-center" + :aria-label="__('Loading issues')" + data-testid="board_list_loading" + > + <gl-loading-icon /> + </div> + <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" /> + <ul + v-show="!loading" + ref="list" + :data-board="list.id" + :data-board-type="list.type" + :class="{ 'bg-danger-100': issuesSizeExceedsMax }" + class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list" + > + <board-card + v-for="(issue, index) in issues" + ref="issue" + :key="issue.id" + :index="index" + :list="list" + :issue="issue" + :disabled="disabled" + /> + <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> + <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" /> + <span v-if="issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> + <span v-else>{{ paginatedIssueText }}</span> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 348d485ff37..0a665b82880 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -22,11 +22,7 @@ export default { required: true, }, }, - inject: { - groupId: { - type: Number, - }, - }, + inject: ['groupId'], data() { return { title: '', diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index e2600883e89..392e056dcbf 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -1,5 +1,5 @@ <script> -import { GlDrawer, GlLabel } from '@gitlab/ui'; +import { GlButton, GlDrawer, GlLabel } from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; import { __ } from '~/locale'; import boardsStore from '~/boards/stores/boards_store'; @@ -17,6 +17,7 @@ export default { label: 'label', labelListText: __('Label'), components: { + GlButton, GlDrawer, GlLabel, BoardSettingsSidebarWipLimit: () => @@ -25,16 +26,23 @@ export default { import('ee_component/boards/components/board_settings_list_types.vue'), }, mixins: [glFeatureFlagMixin()], + props: { + canAdminList: { + type: Boolean, + required: false, + default: false, + }, + }, computed: { - ...mapGetters(['isSidebarOpen']), + ...mapGetters(['isSidebarOpen', 'shouldUseGraphQL']), ...mapState(['activeId', 'sidebarType', 'boardLists']), activeList() { /* Warning: Though a computed property it is not reactive because we are referencing a List Model class. Reactivity only applies to plain JS objects */ - if (this.glFeatures.graphqlBoardLists) { - return this.boardLists.find(({ id }) => id === this.activeId); + if (this.shouldUseGraphQL) { + return this.boardLists[this.activeId]; } return boardsStore.state.lists.find(({ id }) => id === this.activeId); }, @@ -62,6 +70,13 @@ export default { showScopedLabels(label) { return boardsStore.scopedLabels.enabled && isScopedLabel(label); }, + deleteBoard() { + // eslint-disable-next-line no-alert + if (window.confirm(__('Are you sure you want to delete this list?'))) { + this.activeList.destroy(); + this.unsetActiveId(); + } + }, }, }; </script> @@ -91,6 +106,16 @@ export default { :board-list-type="boardListType" /> <board-settings-sidebar-wip-limit :max-issue-count="activeList.maxIssueCount" /> + <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-m-4"> + <gl-button + variant="danger" + category="secondary" + icon="remove" + data-testid="remove-list" + @click.stop="deleteBoard" + >{{ __('Remove list') }} + </gl-button> + </div> </template> </gl-drawer> </template> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 8658f51e5cf..a181ea51c4a 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -41,14 +41,7 @@ export default { default: false, }, }, - inject: { - groupId: { - type: Number, - }, - rootPath: { - type: String, - }, - }, + inject: ['groupId', 'rootPath'], data() { return { limitBeforeCounter: 2, diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue index a71fda9d7c5..b066fb25360 100644 --- a/app/assets/javascripts/boards/components/modal/tabs.vue +++ b/app/assets/javascripts/boards/components/modal/tabs.vue @@ -1,9 +1,15 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ +import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; export default { + components: { + GlTabs, + GlTab, + GlBadge, + }, mixins: [modalMixin], data() { return ModalStore.store; @@ -19,18 +25,18 @@ export default { }; </script> <template> - <div class="top-area gl-mt-3 gl-mb-3"> - <ul class="nav-links issues-state-filters"> - <li :class="{ active: activeTab == 'all' }"> - <a href="#" role="button" @click.prevent="changeTab('all')"> - Open issues <span class="badge badge-pill"> {{ issuesCount }} </span> - </a> - </li> - <li :class="{ active: activeTab == 'selected' }"> - <a href="#" role="button" @click.prevent="changeTab('selected')"> - Selected issues <span class="badge badge-pill"> {{ selectedCount }} </span> - </a> - </li> - </ul> - </div> + <gl-tabs class="gl-mt-3"> + <gl-tab @click.prevent="changeTab('all')"> + <template slot="title"> + <span>Open issues</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ issuesCount }}</gl-badge> + </template> + </gl-tab> + <gl-tab @click.prevent="changeTab('selected')"> + <template slot="title"> + <span>Selected issues</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ selectedCount }}</gl-badge> + </template> + </gl-tab> + </gl-tabs> </template> diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 2e356f1353a..c8926c5ef2a 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -6,8 +6,14 @@ import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from '~/flash'; import CreateLabelDropdown from '../../create_label'; import boardsStore from '../stores/boards_store'; +import { fullLabelId } from '../boards_util'; +import store from '~/boards/stores'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +function shouldCreateListGraphQL(label) { + return store.getters.shouldUseGraphQL && !store.getters.getListByLabelId(fullLabelId(label)); +} + $(document) .off('created.label') .on('created.label', (e, label, addNewList) => { @@ -15,16 +21,20 @@ $(document) return; } - boardsStore.new({ - title: label.title, - position: boardsStore.state.lists.length - 2, - list_type: 'label', - label: { - id: label.id, + if (shouldCreateListGraphQL(label)) { + store.dispatch('createList', { labelId: fullLabelId(label) }); + } else { + boardsStore.new({ title: label.title, - color: label.color, - }, - }); + position: boardsStore.state.lists.length - 2, + list_type: 'label', + label: { + id: label.id, + title: label.title, + color: label.color, + }, + }); + } }); export default function initNewListDropdown() { @@ -74,7 +84,9 @@ export default function initNewListDropdown() { const label = options.selectedObj; e.preventDefault(); - if (!boardsStore.findListByLabelId(label.id)) { + if (shouldCreateListGraphQL(label)) { + store.dispatch('createList', { labelId: fullLabelId(label) }); + } else if (!boardsStore.findListByLabelId(label.id)) { boardsStore.new({ title: label.title, position: boardsStore.state.lists.length - 2, diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 59e7620962a..566c0081b9d 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -20,11 +20,7 @@ export default { required: true, }, }, - inject: { - groupId: { - type: Number, - }, - }, + inject: ['groupId'], data() { return { loading: true, diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue index 8df03ea581f..5fb7a9b210c 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -36,16 +36,18 @@ export default { } this.edit = true; - this.$emit('changed', this.edit); + this.$emit('open'); window.addEventListener('click', this.collapseWhenOffClick); }, - collapse() { + collapse({ emitEvent = true } = {}) { if (!this.edit) { return; } this.edit = false; - this.$emit('changed', this.edit); + if (emitEvent) { + this.$emit('close'); + } window.removeEventListener('click', this.collapseWhenOffClick); }, }, diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue new file mode 100644 index 00000000000..0f063c7582e --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -0,0 +1,120 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import { GlLabel } from '@gitlab/ui'; +import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +export default { + components: { + BoardEditableItem, + LabelsSelect, + GlLabel, + }, + data() { + return { + loading: false, + }; + }, + inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'], + computed: { + ...mapGetters({ issue: 'getActiveIssue' }), + selectedLabels() { + const { labels = [] } = this.issue; + + return labels.map(label => ({ + ...label, + id: getIdFromGraphQLId(label.id), + })); + }, + issueLabels() { + const { labels = [] } = this.issue; + + return labels.map(label => ({ + ...label, + scoped: isScopedLabel(label), + })); + }, + projectPath() { + const { referencePath = '' } = this.issue; + return referencePath.slice(0, referencePath.indexOf('#')); + }, + }, + methods: { + ...mapActions(['setActiveIssueLabels']), + async setLabels(payload) { + this.loading = true; + this.$refs.sidebarItem.collapse(); + + try { + const addLabelIds = payload.filter(label => label.set).map(label => label.id); + const removeLabelIds = this.selectedLabels + .filter(label => !payload.find(selected => selected.id === label.id)) + .map(label => label.id); + + const input = { addLabelIds, removeLabelIds, projectPath: this.projectPath }; + await this.setActiveIssueLabels(input); + } catch (e) { + createFlash({ message: __('An error occurred while updating labels.') }); + } finally { + this.loading = false; + } + }, + async removeLabel(id) { + this.loading = true; + + try { + const removeLabelIds = [getIdFromGraphQLId(id)]; + const input = { removeLabelIds, projectPath: this.projectPath }; + await this.setActiveIssueLabels(input); + } catch (e) { + createFlash({ message: __('An error occurred when removing the label.') }); + } finally { + this.loading = false; + } + }, + }, +}; +</script> + +<template> + <board-editable-item ref="sidebarItem" :title="__('Labels')" :loading="loading"> + <template #collapsed> + <gl-label + v-for="label in issueLabels" + :key="label.id" + :background-color="label.color" + :title="label.title" + :description="label.description" + :scoped="label.scoped" + :show-close-button="true" + :disabled="loading" + class="gl-mr-2 gl-mb-2" + @close="removeLabel(label.id)" + /> + </template> + <template> + <labels-select + ref="labelsSelect" + :allow-label-edit="false" + :allow-label-create="false" + :allow-multiselect="true" + :allow-scoped-labels="true" + :selected-labels="selectedLabels" + :labels-fetch-path="labelsFetchPath" + :labels-manage-path="labelsManagePath" + :labels-filter-base-path="labelsFilterBasePath" + :labels-list-title="__('Select label')" + :dropdown-button-text="__('Choose labels')" + variant="embedded" + class="gl-display-block labels gl-w-full" + @updateSelectedLabels="setLabels" + > + {{ __('None') }} + </labels-select> + </template> + </board-editable-item> +</template> diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js index 583270fcae5..419a640d5c5 100644 --- a/app/assets/javascripts/boards/ee_functions.js +++ b/app/assets/javascripts/boards/ee_functions.js @@ -1,6 +1,6 @@ export const setPromotionState = () => {}; -export const setWeigthFetchingState = () => {}; +export const setWeightFetchingState = () => {}; export const setEpicFetchingState = () => {}; export const getMilestoneTitle = () => ({}); diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index fff89832bf0..4fa78ecd5a4 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -25,7 +25,8 @@ export default class FilteredSearchBoards extends FilteredSearchManager { } updateObject(path) { - this.store.path = path.substr(1); + const groupByParam = new URLSearchParams(window.location.search).get('group_by'); + this.store.path = `${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`; if (gon.features.boardsWithSwimlanes || gon.features.graphqlBoardLists) { boardsStore.updateFiltersUrl(); diff --git a/app/assets/javascripts/boards/icons/fullscreen_collapse.svg b/app/assets/javascripts/boards/icons/fullscreen_collapse.svg deleted file mode 100644 index 6bd773dc4c5..00000000000 --- a/app/assets/javascripts/boards/icons/fullscreen_collapse.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="17" height="17" viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg"><path d="M.147 15.496l2.146-2.146-1.286-1.286a.55.55 0 0 1-.125-.616c.101-.238.277-.357.527-.357h4a.55.55 0 0 1 .402.17.55.55 0 0 1 .17.401v4c0 .25-.12.426-.358.527-.232.101-.437.06-.616-.125l-1.286-1.286-2.146 2.146-1.428-1.428zM14.996.646l1.428 1.43-2.146 2.145 1.286 1.286c.185.179.226.384.125.616-.101.238-.277.357-.527.357h-4a.55.55 0 0 1-.402-.17.55.55 0 0 1-.17-.401v-4c0-.25.12-.426.358-.527a.553.553 0 0 1 .616.125l1.286 1.286L14.996.647zm-13.42 0L3.72 2.794l1.286-1.286a.55.55 0 0 1 .616-.125c.238.101.357.277.357.527v4a.55.55 0 0 1-.17.402.55.55 0 0 1-.401.17h-4c-.25 0-.426-.12-.527-.358-.101-.232-.06-.437.125-.616l1.286-1.286L.147 2.075 1.575.647zm14.848 14.85l-1.428 1.428-2.146-2.146-1.286 1.286c-.179.185-.384.226-.616.125-.238-.101-.357-.277-.357-.527v-4a.55.55 0 0 1 .17-.402.55.55 0 0 1 .401-.17h4c.25 0 .426.12.527.358a.553.553 0 0 1-.125.616l-1.286 1.286 2.146 2.146z" fill-rule="evenodd"/></svg> diff --git a/app/assets/javascripts/boards/icons/fullscreen_expand.svg b/app/assets/javascripts/boards/icons/fullscreen_expand.svg deleted file mode 100644 index 306073b8af2..00000000000 --- a/app/assets/javascripts/boards/icons/fullscreen_expand.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="15" height="15" viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg"><path d="M8.591 5.056l2.147-2.146-1.286-1.286a.55.55 0 0 1-.125-.616c.101-.238.277-.357.527-.357h4a.55.55 0 0 1 .402.17.55.55 0 0 1 .17.401v4c0 .25-.12.426-.358.527-.232.101-.437.06-.616-.125l-1.286-1.286-2.146 2.147-1.429-1.43zM5.018 8.553l1.429 1.43L4.3 12.127l1.286 1.286c.185.179.226.384.125.616-.101.238-.277.357-.527.357h-4a.55.55 0 0 1-.402-.17.55.55 0 0 1-.17-.401v-4c0-.25.12-.426.358-.527a.553.553 0 0 1 .616.125L2.872 10.7l2.146-2.147zm4.964 0l2.146 2.147 1.286-1.286a.55.55 0 0 1 .616-.125c.238.101.357.277.357.527v4a.55.55 0 0 1-.17.402.55.55 0 0 1-.401.17h-4c-.25 0-.426-.12-.527-.358-.101-.232-.06-.437.125-.616l1.286-1.286-2.147-2.146 1.43-1.429zM6.447 5.018l-1.43 1.429L2.873 4.3 1.586 5.586c-.179.185-.384.226-.616.125-.238-.101-.357-.277-.357-.527v-4a.55.55 0 0 1 .17-.402.55.55 0 0 1 .401-.17h4c.25 0 .426.12.527.358a.553.553 0 0 1-.125.616L4.3 2.872l2.147 2.146z" fill-rule="evenodd"/></svg> diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 1173c6d0578..887abe79059 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,4 +1,3 @@ -import $ from 'jquery'; import Vue from 'vue'; import { mapActions, mapState } from 'vuex'; @@ -11,7 +10,7 @@ import toggleLabels from 'ee_else_ce/boards/toggle_labels'; import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes'; import { setPromotionState, - setWeigthFetchingState, + setWeightFetchingState, setEpicFetchingState, getMilestoneTitle, getBoardsModalData, @@ -19,6 +18,7 @@ import { import VueApollo from 'vue-apollo'; import BoardContent from '~/boards/components/board_content.vue'; +import BoardExtraActions from '~/boards/components/board_extra_actions.vue'; import createDefaultClient from '~/lib/graphql'; import { deprecatedCreateFlash as Flash } from '~/flash'; import { __ } from '~/locale'; @@ -84,8 +84,12 @@ export default () => { }, provide: { boardId: $boardApp.dataset.boardId, - groupId: Number($boardApp.dataset.groupId) || null, + groupId: Number($boardApp.dataset.groupId), rootPath: $boardApp.dataset.rootPath, + canUpdate: $boardApp.dataset.canUpdate, + labelsFetchPath: $boardApp.dataset.labelsFetchPath, + labelsManagePath: $boardApp.dataset.labelsManagePath, + labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath, }, store, apolloProvider, @@ -131,6 +135,7 @@ export default () => { eventHub.$on('clearDetailIssue', this.clearDetailIssue); sidebarEventHub.$on('toggleSubscription', this.toggleSubscription); eventHub.$on('performSearch', this.performSearch); + eventHub.$on('initialBoardLoad', this.initialBoardLoad); }, beforeDestroy() { eventHub.$off('updateTokens', this.updateTokens); @@ -138,6 +143,7 @@ export default () => { eventHub.$off('clearDetailIssue', this.clearDetailIssue); sidebarEventHub.$off('toggleSubscription', this.toggleSubscription); eventHub.$off('performSearch', this.performSearch); + eventHub.$off('initialBoardLoad', this.initialBoardLoad); }, mounted() { this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit); @@ -148,6 +154,19 @@ export default () => { boardsStore.disabled = this.disabled; if (!gon.features.graphqlBoardLists) { + this.initialBoardLoad(); + } + }, + methods: { + ...mapActions([ + 'setInitialBoardData', + 'setFilters', + 'fetchEpicsSwimlanes', + 'resetIssues', + 'resetEpics', + 'fetchLists', + ]), + initialBoardLoad() { boardsStore .all() .then(res => res.data) @@ -160,30 +179,26 @@ export default () => { .catch(() => { Flash(__('An error occurred while fetching the board lists. Please try again.')); }); - } - }, - methods: { - ...mapActions([ - 'setInitialBoardData', - 'setFilters', - 'fetchEpicsSwimlanes', - 'fetchIssuesForAllLists', - ]), + }, updateTokens() { this.filterManager.updateTokens(); }, performSearch() { this.setFilters(convertObjectPropsToCamelCase(urlParamsToObject(window.location.search))); if (gon.features.boardsWithSwimlanes && this.isShowingEpicsSwimlanes) { - this.fetchEpicsSwimlanes(false); - this.fetchIssuesForAllLists(); + this.resetEpics(); + this.resetIssues(); + this.fetchEpicsSwimlanes({}); + } else if (gon.features.graphqlBoardLists && !this.isShowingEpicsSwimlanes) { + this.fetchLists(); + this.resetIssues(); } }, updateDetailIssue(newIssue, multiSelect = false) { const { sidebarInfoEndpoint } = newIssue; if (sidebarInfoEndpoint && newIssue.subscribed === undefined) { newIssue.setFetchingState('subscriptions', true); - setWeigthFetchingState(newIssue, true); + setWeightFetchingState(newIssue, true); setEpicFetchingState(newIssue, true); boardsStore .getIssueInfo(sidebarInfoEndpoint) @@ -201,7 +216,7 @@ export default () => { } = convertObjectPropsToCamelCase(data); newIssue.setFetchingState('subscriptions', false); - setWeigthFetchingState(newIssue, false); + setWeightFetchingState(newIssue, false); setEpicFetchingState(newIssue, false); newIssue.updateData({ humanTimeSpent: humanTotalTimeSpent, @@ -216,7 +231,7 @@ export default () => { }) .catch(() => { newIssue.setFetchingState('subscriptions', false); - setWeigthFetchingState(newIssue, false); + setWeightFetchingState(newIssue, false); Flash(__('An error occurred while fetching sidebar data')); }); } @@ -300,63 +315,32 @@ export default () => { } return !this.store.lists.filter(list => !list.preset).length; }, - tooltipTitle() { - if (this.disabled) { - return __('Please add a list to your board first'); - } - - return ''; - }, - }, - watch: { - disabled() { - this.updateTooltip(); - }, - }, - mounted() { - this.updateTooltip(); }, methods: { - updateTooltip() { - const $tooltip = $(this.$refs.addIssuesButton); - - this.$nextTick(() => { - if (this.disabled) { - $tooltip.tooltip(); - } else { - $tooltip.tooltip('dispose'); - } - }); - }, openModal() { if (!this.disabled) { this.toggleModal(true); } }, }, - template: ` - <div class="board-extra-actions"> - <button - class="btn btn-success gl-ml-3" - type="button" - data-placement="bottom" - data-track-event="click_button" - data-track-label="board_add_issues" - ref="addIssuesButton" - :class="{ 'disabled': disabled }" - :title="tooltipTitle" - :aria-disabled="disabled" - v-if="canAdminList" - @click="openModal"> - Add issues - </button> - </div> - `, + render(createElement) { + return createElement(BoardExtraActions, { + props: { + canAdminList: this.$options.el.hasAttribute('data-can-admin-list'), + openModal: this.openModal, + disabled: this.disabled, + }, + }); + }, }); } toggleFocusMode(ModalStore, boardsStore); toggleLabels(); - toggleEpicsSwimlanes(); + + if (gon.features?.swimlanes) { + toggleEpicsSwimlanes(); + } + mountMultipleBoardsSwitcher(); }; diff --git a/app/assets/javascripts/boards/mixins/is_wip_limits.js b/app/assets/javascripts/boards/mixins/is_wip_limits.js deleted file mode 100644 index f172179d3c7..00000000000 --- a/app/assets/javascripts/boards/mixins/is_wip_limits.js +++ /dev/null @@ -1,7 +0,0 @@ -export default { - computed: { - isWipLimitsOn() { - return false; - }, - }, -}; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 2f6caffbf84..09f5d5b4dd8 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -1,4 +1,4 @@ -/* eslint-disable no-underscore-dangle, class-methods-use-this */ +/* eslint-disable class-methods-use-this */ import { __ } from '~/locale'; import ListLabel from './label'; import ListAssignee from './assignee'; @@ -34,7 +34,6 @@ const TYPES = { class List { constructor(obj) { this.id = obj.id; - this._uid = this.guid(); this.position = obj.position; this.title = (obj.list_type || obj.listType) === 'backlog' ? __('Open') : obj.title; this.type = obj.list_type || obj.listType; diff --git a/app/assets/javascripts/boards/queries/board.mutation.graphql b/app/assets/javascripts/boards/queries/board.mutation.graphql new file mode 100644 index 00000000000..ef2b81a7939 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board.mutation.graphql @@ -0,0 +1,11 @@ +mutation UpdateBoard($id: ID!, $hideClosedList: Boolean, $hideBacklogList: Boolean) { + updateBoard( + input: { id: $id, hideClosedList: $hideClosedList, hideBacklogList: $hideBacklogList } + ) { + board { + id + hideClosedList + hideBacklogList + } + } +} diff --git a/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql b/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql index dcfe69222a0..48420b349ae 100644 --- a/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql +++ b/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql @@ -1,7 +1,21 @@ -#import "./board_list.fragment.graphql" +#import "ee_else_ce/boards/queries/board_list.fragment.graphql" -mutation CreateBoardList($boardId: BoardID!, $backlog: Boolean) { - boardListCreate(input: { boardId: $boardId, backlog: $backlog }) { +mutation CreateBoardList( + $boardId: BoardID! + $backlog: Boolean + $labelId: LabelID + $milestoneId: MilestoneID + $assigneeId: UserID +) { + boardListCreate( + input: { + boardId: $boardId + backlog: $backlog + labelId: $labelId + milestoneId: $milestoneId + assigneeId: $assigneeId + } + ) { list { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/queries/board_lists.query.graphql b/app/assets/javascripts/boards/queries/board_lists.query.graphql new file mode 100644 index 00000000000..88425e9a9c1 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board_lists.query.graphql @@ -0,0 +1,28 @@ +#import "ee_else_ce/boards/queries/board_list.fragment.graphql" + +query ListIssues( + $fullPath: ID! + $boardId: ID! + $filters: BoardIssueInput + $isGroup: Boolean = false + $isProject: Boolean = false +) { + group(fullPath: $fullPath) @include(if: $isGroup) { + board(id: $boardId) { + lists(issueFilters: $filters) { + nodes { + ...BoardListFragment + } + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + board(id: $boardId) { + lists(issueFilters: $filters) { + nodes { + ...BoardListFragment + } + } + } + } +} diff --git a/app/assets/javascripts/boards/queries/group_board.query.graphql b/app/assets/javascripts/boards/queries/group_board.query.graphql deleted file mode 100644 index cb42cb3f73d..00000000000 --- a/app/assets/javascripts/boards/queries/group_board.query.graphql +++ /dev/null @@ -1,13 +0,0 @@ -#import "ee_else_ce/boards/queries/board_list.fragment.graphql" - -query GroupBoard($fullPath: ID!, $boardId: ID!) { - group(fullPath: $fullPath) { - board(id: $boardId) { - lists { - nodes { - ...BoardListFragment - } - } - } - } -} diff --git a/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql b/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql new file mode 100644 index 00000000000..3c5f4b3e3bd --- /dev/null +++ b/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql @@ -0,0 +1,15 @@ +mutation issueSetLabels($input: UpdateIssueInput!) { + updateIssue(input: $input) { + issue { + labels { + nodes { + id + title + color + description + } + } + } + errors + } +} diff --git a/app/assets/javascripts/boards/queries/lists_issues.query.graphql b/app/assets/javascripts/boards/queries/lists_issues.query.graphql index c66cdf68cf4..5dbfe4675c6 100644 --- a/app/assets/javascripts/boards/queries/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/queries/lists_issues.query.graphql @@ -7,15 +7,24 @@ query ListIssues( $filters: BoardIssueInput $isGroup: Boolean = false $isProject: Boolean = false + $after: String + $first: Int ) { group(fullPath: $fullPath) @include(if: $isGroup) { board(id: $boardId) { lists(id: $id) { nodes { id - issues(filters: $filters) { - nodes { - ...IssueNode + issues(first: $first, filters: $filters, after: $after) { + count + edges { + node { + ...IssueNode + } + } + pageInfo { + endCursor + hasNextPage } } } @@ -27,9 +36,16 @@ query ListIssues( lists(id: $id) { nodes { id - issues(filters: $filters) { - nodes { - ...IssueNode + issues(first: $first, filters: $filters, after: $after) { + count + edges { + node { + ...IssueNode + } + } + pageInfo { + endCursor + hasNextPage } } } diff --git a/app/assets/javascripts/boards/queries/project_board.query.graphql b/app/assets/javascripts/boards/queries/project_board.query.graphql deleted file mode 100644 index 4620a7e0fd5..00000000000 --- a/app/assets/javascripts/boards/queries/project_board.query.graphql +++ /dev/null @@ -1,13 +0,0 @@ -#import "ee_else_ce/boards/queries/board_list.fragment.graphql" - -query ProjectBoard($fullPath: ID!, $boardId: ID!) { - project(fullPath: $fullPath) { - board(id: $boardId) { - lists { - nodes { - ...BoardListFragment - } - } - } - } -} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 4b81d9c73ef..1fed1228106 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,28 +1,38 @@ import Cookies from 'js-cookie'; -import { sortBy, pick } from 'lodash'; -import createFlash from '~/flash'; +import { pick } from 'lodash'; + +import boardListsQuery from 'ee_else_ce/boards/queries/board_lists.query.graphql'; import { __ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; -import createDefaultClient from '~/lib/graphql'; +import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { BoardType, ListType, inactiveId } from '~/boards/constants'; import * as types from './mutation_types'; -import { formatListIssues, fullBoardId } from '../boards_util'; +import { + formatBoardLists, + formatListIssues, + fullBoardId, + formatListsPageInfo, +} from '../boards_util'; import boardStore from '~/boards/stores/boards_store'; import listsIssuesQuery from '../queries/lists_issues.query.graphql'; -import projectBoardQuery from '../queries/project_board.query.graphql'; -import groupBoardQuery from '../queries/group_board.query.graphql'; import createBoardListMutation from '../queries/board_list_create.mutation.graphql'; import updateBoardListMutation from '../queries/board_list_update.mutation.graphql'; import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql'; +import issueSetLabels from '../queries/issue_set_labels.mutation.graphql'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ throw new Error('Not implemented!'); }; -export const gqlClient = createDefaultClient(); +export const gqlClient = createGqClient( + {}, + { + fetchPolicy: fetchPolicies.NO_CACHE, + }, +); export default { setInitialBoardData: ({ commit }, data) => { @@ -50,62 +60,46 @@ export default { }, fetchLists: ({ commit, state, dispatch }) => { - const { endpoints, boardType } = state; + const { endpoints, boardType, filterParams } = state; const { fullPath, boardId } = endpoints; - let query; - if (boardType === BoardType.group) { - query = groupBoardQuery; - } else if (boardType === BoardType.project) { - query = projectBoardQuery; - } else { - createFlash(__('Invalid board')); - return Promise.reject(); - } - const variables = { fullPath, boardId: fullBoardId(boardId), + filters: filterParams, + isGroup: boardType === BoardType.group, + isProject: boardType === BoardType.project, }; return gqlClient .query({ - query, + query: boardListsQuery, variables, }) .then(({ data }) => { - let { lists } = data[boardType]?.board; - // Temporarily using positioning logic from boardStore - lists = lists.nodes.map(list => - boardStore.updateListPosition({ - ...list, - doNotFetchIssues: true, - }), - ); - commit(types.RECEIVE_BOARD_LISTS_SUCCESS, sortBy(lists, 'position')); - // Backlog list needs to be created if it doesn't exist - if (!lists.find(l => l.type === ListType.backlog)) { + const { lists, hideBacklogList } = data[boardType]?.board; + commit(types.RECEIVE_BOARD_LISTS_SUCCESS, formatBoardLists(lists)); + // Backlog list needs to be created if it doesn't exist and it's not hidden + if (!lists.nodes.find(l => l.listType === ListType.backlog) && !hideBacklogList) { dispatch('createList', { backlog: true }); } dispatch('showWelcomeList'); }) - .catch(() => { - createFlash( - __('An error occurred while fetching the board lists. Please reload the page.'), - ); - }); + .catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE)); }, - // This action only supports backlog list creation at this stage - // Future iterations will add the ability to create other list types - createList: ({ state, commit, dispatch }, { backlog = false }) => { + createList: ({ state, commit, dispatch }, { backlog, labelId, milestoneId, assigneeId }) => { const { boardId } = state.endpoints; + gqlClient .mutate({ mutation: createBoardListMutation, variables: { boardId: fullBoardId(boardId), backlog, + labelId, + milestoneId, + assigneeId, }, }) .then(({ data }) => { @@ -116,16 +110,15 @@ export default { dispatch('addList', list); } }) - .catch(() => { - commit(types.CREATE_LIST_FAILURE); - }); + .catch(() => commit(types.CREATE_LIST_FAILURE)); }, - addList: ({ state, commit }, list) => { - const lists = state.boardLists; + addList: ({ commit }, list) => { // Temporarily using positioning logic from boardStore - lists.push(boardStore.updateListPosition({ ...list, doNotFetchIssues: true })); - commit(types.RECEIVE_BOARD_LISTS_SUCCESS, sortBy(lists, 'position')); + commit( + types.RECEIVE_ADD_LIST_SUCCESS, + boardStore.updateListPosition({ ...list, doNotFetchIssues: true }), + ); }, showWelcomeList: ({ state, dispatch }) => { @@ -133,7 +126,9 @@ export default { return; } if ( - state.boardLists.find(list => list.type !== ListType.backlog && list.type !== ListType.closed) + Object.entries(state.boardLists).find( + ([, list]) => list.type !== ListType.backlog && list.type !== ListType.closed, + ) ) { return; } @@ -155,13 +150,16 @@ export default { notImplemented(); }, - moveList: ({ state, commit, dispatch }, { listId, newIndex, adjustmentValue }) => { + moveList: ( + { state, commit, dispatch }, + { listId, replacedListId, newIndex, adjustmentValue }, + ) => { const { boardLists } = state; - const backupList = [...boardLists]; - const movedList = boardLists.find(({ id }) => id === listId); + const backupList = { ...boardLists }; + const movedList = boardLists[listId]; const newPosition = newIndex - 1; - const listAtNewIndex = boardLists[newIndex]; + const listAtNewIndex = boardLists[replacedListId]; movedList.position = newPosition; listAtNewIndex.position += adjustmentValue; @@ -197,7 +195,9 @@ export default { notImplemented(); }, - fetchIssuesForList: ({ state, commit }, listId) => { + fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => { + commit(types.REQUEST_ISSUES_FOR_LIST, { listId, fetchNext }); + const { endpoints, boardType, filterParams } = state; const { fullPath, boardId } = endpoints; @@ -208,6 +208,8 @@ export default { filters: filterParams, isGroup: boardType === BoardType.group, isProject: boardType === BoardType.project, + first: 20, + after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined, }; return gqlClient @@ -221,36 +223,14 @@ export default { .then(({ data }) => { const { lists } = data[boardType]?.board; const listIssues = formatListIssues(lists); - commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listId }); + const listPageInfo = formatListsPageInfo(lists); + commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listPageInfo, listId }); }) .catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId)); }, - fetchIssuesForAllLists: ({ state, commit }) => { - commit(types.REQUEST_ISSUES_FOR_ALL_LISTS); - - const { endpoints, boardType, filterParams } = state; - const { fullPath, boardId } = endpoints; - - const variables = { - fullPath, - boardId: fullBoardId(boardId), - filters: filterParams, - isGroup: boardType === BoardType.group, - isProject: boardType === BoardType.project, - }; - - return gqlClient - .query({ - query: listsIssuesQuery, - variables, - }) - .then(({ data }) => { - const { lists } = data[boardType]?.board; - const listIssues = formatListIssues(lists); - commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS, listIssues); - }) - .catch(() => commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE)); + resetIssues: ({ commit }) => { + commit(types.RESET_ISSUES); }, moveIssue: ( @@ -303,6 +283,31 @@ export default { commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issue }); }, + setActiveIssueLabels: async ({ commit, getters }, input) => { + const activeIssue = getters.getActiveIssue; + const { data } = await gqlClient.mutate({ + mutation: issueSetLabels, + variables: { + input: { + iid: String(activeIssue.iid), + addLabelIds: input.addLabelIds ?? [], + removeLabelIds: input.removeLabelIds ?? [], + projectPath: input.projectPath, + }, + }, + }); + + if (data.updateIssue?.errors?.length > 0) { + throw new Error(data.updateIssue.errors); + } + + commit(types.UPDATE_ISSUE_BY_ID, { + issueId: activeIssue.id, + prop: 'labels', + value: data.updateIssue.issue.labels.nodes, + }); + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index faf4f9ebfd3..d1a5db1bcc5 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -2,7 +2,7 @@ /* global List */ /* global ListIssue */ import $ from 'jquery'; -import { sortBy } from 'lodash'; +import { sortBy, pick } from 'lodash'; import Vue from 'vue'; import Cookies from 'js-cookie'; import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee'; @@ -12,7 +12,7 @@ import { parseBoolean, convertObjectPropsToCamelCase, } from '~/lib/utils/common_utils'; -import { __ } from '~/locale'; +import createDefaultClient from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -23,7 +23,11 @@ import ListLabel from '../models/label'; import ListAssignee from '../models/assignee'; import ListMilestone from '../models/milestone'; +import createBoardMutation from '../queries/board.mutation.graphql'; + const PER_PAGE = 20; +export const gqlClient = createDefaultClient(); + const boardsStore = { disabled: false, timeTracking: { @@ -114,7 +118,6 @@ const boardsStore = { .catch(() => { // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821 }); - this.removeBlankState(); }, updateNewListDropdown(listId) { $(`.js-board-list-${listId}`).removeClass('is-active'); @@ -124,22 +127,14 @@ const boardsStore = { return !this.state.lists.filter(list => list.type !== 'backlog' && list.type !== 'closed')[0]; }, addBlankState() { - if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; - - this.addList({ - id: 'blank', - list_type: 'blank', - title: __('Welcome to your Issue Board!'), - position: 0, - }); - }, - removeBlankState() { - this.removeList('blank'); + if (!this.shouldAddBlankState() || this.welcomeIsHidden()) return; - Cookies.set('issue_board_welcome_hidden', 'true', { - expires: 365 * 10, - path: '', - }); + this.generateDefaultLists() + .then(res => res.data) + .then(data => Promise.all(data.map(list => this.addList(list)))) + .catch(() => { + this.removeList(undefined, 'label'); + }); }, findIssueLabel(issue, findLabel) { @@ -542,6 +537,10 @@ const boardsStore = { this.timeTracking.limitToHours = parseBoolean(limitToHours); }, + generateBoardGid(boardId) { + return `gid://gitlab/Board/${boardId}`; + }, + generateBoardsPath(id) { return `${this.state.endpoints.boardsEndpoint}${id ? `/${id}` : ''}.json`; }, @@ -800,9 +799,33 @@ const boardsStore = { } if (boardPayload.id) { - return axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload }); + const input = { + ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']), + id: this.generateBoardGid(boardPayload.id), + }; + + return Promise.all([ + axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload }), + gqlClient.mutate({ + mutation: createBoardMutation, + variables: input, + }), + ]); } - return axios.post(this.generateBoardsPath(), { board: boardPayload }); + + return axios + .post(this.generateBoardsPath(), { board: boardPayload }) + .then(resp => resp.data) + .then(data => { + gqlClient.mutate({ + mutation: createBoardMutation, + variables: { + ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']), + id: this.generateBoardGid(data.id), + }, + }); + return data; + }); }, deleteBoard({ id }) { diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index 3688476dc5f..89a3b14b262 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -1,10 +1,11 @@ +import { find } from 'lodash'; import { inactiveId } from '../constants'; export default { getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), isSidebarOpen: state => state.activeId !== inactiveId, isSwimlanesOn: state => { - if (!gon?.features?.boardsWithSwimlanes) { + if (!gon?.features?.boardsWithSwimlanes && !gon?.features?.swimlanes) { return false; } @@ -22,4 +23,16 @@ export default { getActiveIssue: state => { return state.issues[state.activeId] || {}; }, + + getListByLabelId: state => labelId => { + return find(state.boardLists, l => l.label?.id === labelId); + }, + + getListByTitle: state => title => { + return find(state.boardLists, l => l.title === title); + }, + + shouldUseGraphQL: () => { + return gon?.features?.graphqlBoardLists; + }, }; diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index f0a283f6161..09ab08062df 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -3,6 +3,7 @@ export const SET_FILTERS = 'SET_FILTERS'; export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS'; export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE'; export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS'; +export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE'; export const SHOW_PROMOTION_LIST = 'SHOW_PROMOTION_LIST'; export const REQUEST_ADD_LIST = 'REQUEST_ADD_LIST'; export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS'; @@ -12,11 +13,9 @@ export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST'; export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS'; export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR'; -export const REQUEST_ISSUES_FOR_ALL_LISTS = 'REQUEST_ISSUES_FOR_ALL_LISTS'; +export const REQUEST_ISSUES_FOR_LIST = 'REQUEST_ISSUES_FOR_LIST'; export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE'; export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS'; -export const RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS = 'RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS'; -export const RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE = 'RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE'; export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE'; export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS'; export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR'; @@ -32,3 +31,4 @@ export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; export const UPDATE_ISSUE_BY_ID = 'UPDATE_ISSUE_BY_ID'; +export const RESET_ISSUES = 'RESET_ISSUES'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index faeb3e25a71..0c7dbc0d2ef 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -1,8 +1,8 @@ import Vue from 'vue'; -import { sortBy, pull } from 'lodash'; +import { pull, union } from 'lodash'; import { formatIssue, moveIssueListHelper } from '../boards_util'; import * as mutationTypes from './mutation_types'; -import { __ } from '~/locale'; +import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; const notImplemented = () => { @@ -10,11 +10,13 @@ const notImplemented = () => { throw new Error('Not implemented!'); }; -const removeIssueFromList = (state, listId, issueId) => { +export const removeIssueFromList = ({ state, listId, issueId }) => { Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId)); + const list = state.boardLists[listId]; + Vue.set(state.boardLists, listId, { ...list, issuesSize: list.issuesSize - 1 }); }; -const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => { +export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => { const listIssues = state.issuesByListId[listId]; let newIndex = atIndex || 0; if (moveBeforeId) { @@ -24,6 +26,8 @@ const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atI } listIssues.splice(newIndex, 0, issueId); Vue.set(state.issuesByListId, listId, listIssues); + const list = state.boardLists[listId]; + Vue.set(state.boardLists, listId, { ...list, issuesSize: list.issuesSize + 1 }); }; export default { @@ -39,6 +43,12 @@ export default { state.boardLists = lists; }, + [mutationTypes.RECEIVE_BOARD_LISTS_FAILURE]: state => { + state.error = s__( + 'Boards|An error occurred while fetching the board lists. Please reload the page.', + ); + }, + [mutationTypes.SET_ACTIVE_ID](state, { id, sidebarType }) { state.activeId = id; state.sidebarType = sidebarType; @@ -49,15 +59,15 @@ export default { }, [mutationTypes.CREATE_LIST_FAILURE]: state => { - state.error = __('An error occurred while creating the list. Please try again.'); + state.error = s__('Boards|An error occurred while creating the list. Please try again.'); }, [mutationTypes.REQUEST_ADD_LIST]: () => { notImplemented(); }, - [mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: () => { - notImplemented(); + [mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: (state, list) => { + Vue.set(state.boardLists, list.id, list); }, [mutationTypes.RECEIVE_ADD_LIST_ERROR]: () => { @@ -66,14 +76,12 @@ export default { [mutationTypes.MOVE_LIST]: (state, { movedList, listAtNewIndex }) => { const { boardLists } = state; - const movedListIndex = state.boardLists.findIndex(l => l.id === movedList.id); - Vue.set(boardLists, movedListIndex, movedList); - Vue.set(boardLists, movedListIndex.position + 1, listAtNewIndex); - Vue.set(state, 'boardLists', sortBy(boardLists, 'position')); + Vue.set(boardLists, movedList.id, movedList); + Vue.set(boardLists, listAtNewIndex.id, listAtNewIndex); }, [mutationTypes.UPDATE_LIST_FAILURE]: (state, backupList) => { - state.error = __('An error occurred while updating the list. Please try again.'); + state.error = s__('Boards|An error occurred while updating the list. Please try again.'); Vue.set(state, 'boardLists', backupList); }, @@ -89,28 +97,36 @@ export default { notImplemented(); }, - [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: (state, { listIssues, listId }) => { + [mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => { + Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true }); + }, + + [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: ( + state, + { listIssues, listPageInfo, listId }, + ) => { const { listData, issues } = listIssues; Vue.set(state, 'issues', { ...state.issues, ...issues }); - Vue.set(state.issuesByListId, listId, listData[listId]); - const listIndex = state.boardLists.findIndex(l => l.id === listId); - Vue.set(state.boardLists[listIndex], 'loading', false); + Vue.set( + state.issuesByListId, + listId, + union(state.issuesByListId[listId] || [], listData[listId]), + ); + Vue.set(state.pageInfoByListId, listId, listPageInfo[listId]); + Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false }); }, [mutationTypes.RECEIVE_ISSUES_FOR_LIST_FAILURE]: (state, listId) => { - state.error = __('An error occurred while fetching the board issues. Please reload the page.'); - const listIndex = state.boardLists.findIndex(l => l.id === listId); - Vue.set(state.boardLists[listIndex], 'loading', false); - }, - - [mutationTypes.REQUEST_ISSUES_FOR_ALL_LISTS]: state => { - state.isLoadingIssues = true; + state.error = s__( + 'Boards|An error occurred while fetching the board issues. Please reload the page.', + ); + Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false }); }, - [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS]: (state, { listData, issues }) => { - state.issuesByListId = listData; - state.issues = issues; - state.isLoadingIssues = false; + [mutationTypes.RESET_ISSUES]: state => { + Object.keys(state.issuesByListId).forEach(listId => { + Vue.set(state.issuesByListId, listId, []); + }); }, [mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => { @@ -122,11 +138,6 @@ export default { Vue.set(state.issues[issueId], prop, value); }, - [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE]: state => { - state.error = __('An error occurred while fetching the board issues. Please reload the page.'); - state.isLoadingIssues = false; - }, - [mutationTypes.REQUEST_ADD_ISSUE]: () => { notImplemented(); }, @@ -143,13 +154,13 @@ export default { state, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId }, ) => { - const fromList = state.boardLists.find(l => l.id === fromListId); - const toList = state.boardLists.find(l => l.id === toListId); + const fromList = state.boardLists[fromListId]; + const toList = state.boardLists[toListId]; const issue = moveIssueListHelper(originalIssue, fromList, toList); Vue.set(state.issues, issue.id, issue); - removeIssueFromList(state, fromListId, issue.id); + removeIssueFromList({ state, listId: fromListId, issueId: issue.id }); addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId }); }, @@ -162,9 +173,9 @@ export default { state, { originalIssue, fromListId, toListId, originalIndex }, ) => { - state.error = __('An error occurred while moving the issue. Please try again.'); + state.error = s__('Boards|An error occurred while moving the issue. Please try again.'); Vue.set(state.issues, originalIssue.id, originalIssue); - removeIssueFromList(state, toListId, originalIssue.id); + removeIssueFromList({ state, listId: toListId, issueId: originalIssue.id }); addIssueToList({ state, listId: fromListId, @@ -193,8 +204,8 @@ export default { }, [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => { - state.error = __('An error occurred while creating the issue. Please try again.'); - removeIssueFromList(state, list.id, issue.id); + state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); + removeIssueFromList({ state, listId: list.id, issueId: issue.id }); }, [mutationTypes.SET_CURRENT_PAGE]: () => { diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index be937d68c6c..b91c09f8051 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -8,10 +8,11 @@ export default () => ({ isShowingLabels: true, activeId: inactiveId, sidebarType: '', - boardLists: [], + boardLists: {}, + listsFlags: {}, issuesByListId: {}, + pageInfoByListId: {}, issues: {}, - isLoadingIssues: false, filterParams: {}, error: undefined, // TODO: remove after ce/ee split of board_content.vue diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js index e60e7059192..fa13d3a9e3c 100644 --- a/app/assets/javascripts/boards/toggle_focus.js +++ b/app/assets/javascripts/boards/toggle_focus.js @@ -1,13 +1,15 @@ import $ from 'jquery'; import Vue from 'vue'; -import collapseIcon from './icons/fullscreen_collapse.svg'; -import expandIcon from './icons/fullscreen_expand.svg'; +import { GlIcon } from '@gitlab/ui'; export default (ModalStore, boardsStore) => { const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board'); return new Vue({ el: document.getElementById('js-toggle-focus-btn'), + components: { + GlIcon, + }, data: { modal: ModalStore.store, store: boardsStore.state, @@ -32,12 +34,7 @@ export default (ModalStore, boardsStore) => { title="Toggle focus mode" ref="toggleFocusModeButton" @click="toggleFocusMode"> - <span v-show="isFullscreen"> - ${collapseIcon} - </span> - <span v-show="!isFullscreen"> - ${expandIcon} - </span> + <gl-icon :name="isFullscreen ? 'minimize' : 'maximize'" /> </a> </div> `, diff --git a/app/assets/javascripts/breadcrumb.js b/app/assets/javascripts/breadcrumb.js index a37838694ec..ff7f734f998 100644 --- a/app/assets/javascripts/breadcrumb.js +++ b/app/assets/javascripts/breadcrumb.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { hide } from '~/tooltips'; export const addTooltipToEl = el => { const textEl = el.querySelector('.js-breadcrumb-item-text'); @@ -23,9 +24,11 @@ export default () => { topLevelLinks.forEach(el => addTooltipToEl(el)); $expander.closest('.dropdown').on('show.bs.dropdown hide.bs.dropdown', e => { - $('.js-breadcrumbs-collapsed-expander', e.currentTarget) - .toggleClass('open') - .tooltip('hide'); + const $el = $('.js-breadcrumbs-collapsed-expander', e.currentTarget); + + $el.toggleClass('open'); + + hide($el); }); } }; diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js index 2955f0f014b..8324c649538 100644 --- a/app/assets/javascripts/build_artifacts.js +++ b/app/assets/javascripts/build_artifacts.js @@ -3,6 +3,7 @@ import $ from 'jquery'; import { visitUrl } from './lib/utils/url_utility'; import { parseBoolean } from './lib/utils/common_utils'; +import { hide, initTooltips, show } from '~/tooltips'; export default class BuildArtifacts { constructor() { @@ -10,6 +11,7 @@ export default class BuildArtifacts { this.setupEntryClick(); this.setupTooltips(); } + // eslint-disable-next-line class-methods-use-this disablePropagation() { $('.top-block').on('click', '.download', e => { @@ -19,15 +21,17 @@ export default class BuildArtifacts { e.stopImmediatePropagation(); }); } + // eslint-disable-next-line class-methods-use-this setupEntryClick() { return $('.tree-holder').on('click', 'tr[data-link]', function() { visitUrl(this.dataset.link, parseBoolean(this.dataset.externalLink)); }); } + // eslint-disable-next-line class-methods-use-this setupTooltips() { - $('.js-artifact-tree-tooltip').tooltip({ + initTooltips({ placement: 'bottom', // Stop the tooltip from hiding when we stop hovering the element directly // We handle all the showing/hiding below @@ -38,14 +42,14 @@ export default class BuildArtifacts { // But be placed below and in the middle of the file name $('.js-artifact-tree-row') .on('mouseenter', e => { - $(e.currentTarget) - .find('.js-artifact-tree-tooltip') - .tooltip('show'); + const $el = $(e.currentTarget).find('.js-artifact-tree-tooltip'); + + show($el); }) .on('mouseleave', e => { - $(e.currentTarget) - .find('.js-artifact-tree-tooltip') - .tooltip('hide'); + const $el = $(e.currentTarget).find('.js-artifact-tree-tooltip'); + + hide($el); }); } } diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue index 135d02e4f76..2532f4b86d2 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint.vue +++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue @@ -1,14 +1,120 @@ <script> +import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui'; +import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import CiLintResults from './ci_lint_results.vue'; +import lintCIMutation from '../graphql/mutations/lint_ci.mutation.graphql'; + export default { + components: { + GlButton, + GlFormCheckbox, + GlIcon, + GlLink, + GlAlert, + CiLintResults, + EditorLite, + }, props: { endpoint: { type: String, required: true, }, + helpPagePath: { + type: String, + required: true, + }, + }, + data() { + return { + content: '', + valid: false, + errors: null, + warnings: null, + jobs: [], + dryRun: false, + showingResults: false, + apiError: null, + isErrorDismissed: false, + }; + }, + computed: { + shouldShowError() { + return this.apiError && !this.isErrorDismissed; + }, + }, + methods: { + async lint() { + try { + const { + data: { + lintCI: { valid, errors, warnings, jobs }, + }, + } = await this.$apollo.mutate({ + mutation: lintCIMutation, + variables: { endpoint: this.endpoint, content: this.content, dry: this.dryRun }, + }); + + this.showingResults = true; + this.valid = valid; + this.errors = errors; + this.warnings = warnings; + this.jobs = jobs; + } catch (error) { + this.apiError = error; + this.isErrorDismissed = false; + } + }, + clear() { + this.content = ''; + }, }, }; </script> -<template - ><div></div -></template> +<template> + <div class="row"> + <div class="col-sm-12"> + <gl-alert + v-if="shouldShowError" + class="gl-mb-3" + variant="danger" + @dismiss="isErrorDismissed = true" + >{{ apiError }}</gl-alert + > + <div class="file-holder gl-mb-3"> + <div class="js-file-title file-title clearfix"> + {{ __('Contents of .gitlab-ci.yml') }} + </div> + <editor-lite v-model="content" file-name="*.yml" /> + </div> + </div> + + <div class="col-sm-12 gl-display-flex gl-justify-content-space-between"> + <div class="gl-display-flex gl-align-items-center"> + <gl-button + class="gl-mr-4" + category="primary" + variant="success" + data-testid="ci-lint-validate" + @click="lint" + >{{ __('Validate') }}</gl-button + > + <gl-form-checkbox v-model="dryRun" + >{{ __('Simulate a pipeline created for the default branch') }} + <gl-link :href="helpPagePath" target="_blank" + ><gl-icon class="gl-text-blue-600" name="question-o"/></gl-link + ></gl-form-checkbox> + </div> + <gl-button data-testid="ci-lint-clear" @click="clear">{{ __('Clear') }}</gl-button> + </div> + + <ci-lint-results + v-if="showingResults" + :valid="valid" + :jobs="jobs" + :errors="errors" + :warnings="warnings" + :dry-run="dryRun" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results.vue b/app/assets/javascripts/ci_lint/components/ci_lint_results.vue index 9fd1bd30c49..28b2a028b29 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint_results.vue +++ b/app/assets/javascripts/ci_lint/components/ci_lint_results.vue @@ -1,9 +1,116 @@ <script> +import { GlAlert, GlTable } from '@gitlab/ui'; +import CiLintWarnings from './ci_lint_warnings.vue'; +import CiLintResultsValue from './ci_lint_results_value.vue'; +import CiLintResultsParam from './ci_lint_results_param.vue'; +import { __ } from '~/locale'; + +const thBorderColor = 'gl-border-gray-100!'; + export default { - props: {}, + correct: { variant: 'success', text: __('syntax is correct') }, + incorrect: { variant: 'danger', text: __('syntax is incorrect') }, + warningTitle: __('The form contains the following warning:'), + fields: [ + { + key: 'parameter', + label: __('Parameter'), + thClass: thBorderColor, + }, + { + key: 'value', + label: __('Value'), + thClass: thBorderColor, + }, + ], + components: { + GlAlert, + GlTable, + CiLintWarnings, + CiLintResultsValue, + CiLintResultsParam, + }, + props: { + valid: { + type: Boolean, + required: true, + }, + jobs: { + type: Array, + required: true, + }, + errors: { + type: Array, + required: true, + }, + warnings: { + type: Array, + required: true, + }, + dryRun: { + type: Boolean, + required: true, + }, + }, + data() { + return { + isWarningDismissed: false, + }; + }, + computed: { + status() { + return this.valid ? this.$options.correct : this.$options.incorrect; + }, + shouldShowTable() { + return this.errors.length === 0; + }, + shouldShowError() { + return this.errors.length > 0; + }, + shouldShowWarning() { + return this.warnings.length > 0 && !this.isWarningDismissed; + }, + }, }; </script> -<template - ><div></div -></template> +<template> + <div class="col-sm-12 gl-mt-5"> + <gl-alert + class="gl-mb-5" + :variant="status.variant" + :title="__('Status:')" + :dismissible="false" + data-testid="ci-lint-status" + >{{ status.text }}</gl-alert + > + + <pre + v-if="shouldShowError" + class="gl-mb-5" + data-testid="ci-lint-errors" + ><div v-for="error in errors" :key="error">{{ error }}</div></pre> + + <ci-lint-warnings + v-if="shouldShowWarning" + :warnings="warnings" + data-testid="ci-lint-warnings" + @dismiss="isWarningDismissed = true" + /> + + <gl-table + v-if="shouldShowTable" + :items="jobs" + :fields="$options.fields" + bordered + data-testid="ci-lint-table" + > + <template #cell(parameter)="{ item }"> + <ci-lint-results-param :stage="item.stage" :job-name="item.name" /> + </template> + <template #cell(value)="{ item }"> + <ci-lint-results-value :item="item" :dry-run="dryRun" /> + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue b/app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue new file mode 100644 index 00000000000..23808bcb292 --- /dev/null +++ b/app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue @@ -0,0 +1,26 @@ +<script> +import { __ } from '~/locale'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; + +export default { + props: { + stage: { + type: String, + required: true, + }, + jobName: { + type: String, + required: true, + }, + }, + computed: { + formatParameter() { + return __(`${capitalizeFirstCharacter(this.stage)} Job - ${this.jobName}`); + }, + }, +}; +</script> + +<template> + <span data-testid="ci-lint-parameter">{{ formatParameter }}</span> +</template> diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue b/app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue new file mode 100644 index 00000000000..4929c3206df --- /dev/null +++ b/app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue @@ -0,0 +1,81 @@ +<script> +import { isEmpty } from 'lodash'; + +export default { + props: { + item: { + type: Object, + required: true, + }, + dryRun: { + type: Boolean, + required: true, + }, + }, + computed: { + tagList() { + return this.item.tagList.join(', '); + }, + onlyPolicy() { + return this.item.only ? this.item.only.refs.join(', ') : this.item.only; + }, + exceptPolicy() { + return this.item.except ? this.item.except.refs.join(', ') : this.item.except; + }, + scripts() { + return { + beforeScript: { + show: !isEmpty(this.item.beforeScript), + content: this.item.beforeScript.join('\n'), + }, + script: { + show: !isEmpty(this.item.script), + content: this.item.script.join('\n'), + }, + afterScript: { + show: !isEmpty(this.item.afterScript), + content: this.item.afterScript.join('\n'), + }, + }; + }, + }, +}; +</script> + +<template> + <div> + <pre v-if="scripts.beforeScript.show" data-testid="ci-lint-before-script">{{ + scripts.beforeScript.content + }}</pre> + <pre v-if="scripts.script.show" data-testid="ci-lint-script">{{ scripts.script.content }}</pre> + <pre v-if="scripts.afterScript.show" data-testid="ci-lint-after-script">{{ + scripts.afterScript.content + }}</pre> + + <ul class="gl-list-style-none gl-pl-0 gl-mb-0"> + <li> + <b>{{ __('Tag list:') }}</b> + {{ tagList }} + </li> + <div v-if="!dryRun" data-testid="ci-lint-only-except"> + <li> + <b>{{ __('Only policy:') }}</b> + {{ onlyPolicy }} + </li> + <li> + <b>{{ __('Except policy:') }}</b> + {{ exceptPolicy }} + </li> + </div> + <li> + <b>{{ __('Environment:') }}</b> + {{ item.environment }} + </li> + <li> + <b>{{ __('When:') }}</b> + {{ item.when }} + <b v-if="item.allowFailure">{{ __('Allowed to fail') }}</b> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue b/app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue new file mode 100644 index 00000000000..ac0332cb0bd --- /dev/null +++ b/app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue @@ -0,0 +1,69 @@ +<script> +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { __, n__ } from '~/locale'; + +export default { + maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'), + components: { + GlAlert, + GlSprintf, + }, + props: { + warnings: { + type: Array, + required: true, + }, + maxWarnings: { + type: Number, + required: false, + default: 25, + }, + title: { + type: String, + required: false, + default: __('The form contains the following warning:'), + }, + }, + computed: { + totalWarnings() { + return this.warnings.length; + }, + overMaxWarningsLimit() { + return this.totalWarnings > this.maxWarnings; + }, + warningsSummary() { + return n__('%d warning found:', '%d warnings found:', this.totalWarnings); + }, + summaryMessage() { + return this.overMaxWarningsLimit ? this.$options.maxWarningsSummary : this.warningsSummary; + }, + limitWarnings() { + return this.warnings.slice(0, this.maxWarnings); + }, + }, +}; +</script> + +<template> + <gl-alert class="gl-mb-4" :title="title" variant="warning" @dismiss="$emit('dismiss')"> + <details> + <summary> + <gl-sprintf :message="summaryMessage"> + <template #total> + {{ totalWarnings }} + </template> + <template #warningsDisplayed> + {{ maxWarnings }} + </template> + </gl-sprintf> + </summary> + <p + v-for="(warning, index) in limitWarnings" + :key="`warning-${index}`" + data-testid="ci-lint-warning" + > + {{ warning }} + </p> + </details> + </gl-alert> +</template> diff --git a/app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql b/app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql new file mode 100644 index 00000000000..496036f690f --- /dev/null +++ b/app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql @@ -0,0 +1,22 @@ +mutation lintCI($endpoint: String, $content: String, $dry: Boolean) { + lintCI(endpoint: $endpoint, content: $content, dry_run: $dry) @client { + valid + errors + warnings + jobs { + afterScript + allowFailure + beforeScript + environment + except + name + only { + refs + } + afterScript + stage + tagList + when + } + } +} diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci_lint/index.js index ed2cf1fe714..c41e6d47d75 100644 --- a/app/assets/javascripts/ci_lint/index.js +++ b/app/assets/javascripts/ci_lint/index.js @@ -1,16 +1,57 @@ import Vue from 'vue'; -import CILint from './components/ci_lint.vue'; +import VueApollo from 'vue-apollo'; +import axios from '~/lib/utils/axios_utils'; +import createDefaultClient from '~/lib/graphql'; +import CiLint from './components/ci_lint.vue'; + +Vue.use(VueApollo); + +const resolvers = { + Mutation: { + lintCI: (_, { endpoint, content, dry_run }) => { + return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({ + valid: data.valid, + errors: data.errors, + warnings: data.warnings, + jobs: data.jobs.map(job => ({ + name: job.name, + stage: job.stage, + beforeScript: job.before_script, + script: job.script, + afterScript: job.after_script, + tagList: job.tag_list, + environment: job.environment, + when: job.when, + allowFailure: job.allow_failure, + only: { + refs: job.only.refs, + __typename: 'CiLintJobOnlyPolicy', + }, + except: job.except, + __typename: 'CiLintJob', + })), + __typename: 'CiLintContent', + })); + }, + }, +}; + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers), +}); export default (containerId = '#js-ci-lint') => { const containerEl = document.querySelector(containerId); - const { endpoint } = containerEl.dataset; + const { endpoint, helpPagePath } = containerEl.dataset; return new Vue({ el: containerEl, + apolloProvider, render(createElement) { - return createElement(CILint, { + return createElement(CiLint, { props: { endpoint, + helpPagePath, }, }); }, diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue new file mode 100644 index 00000000000..ad07052a298 --- /dev/null +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -0,0 +1,143 @@ +<script> +import { GlTable, GlButton, GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + GlTable, + GlButton, + GlBadge, + ClipboardButton, + TooltipOnTruncate, + UserAvatarLink, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + triggers: { + type: Array, + required: false, + default: () => [], + }, + }, + fields: [ + { + key: 'token', + label: s__('Pipelines|Token'), + }, + { + key: 'description', + label: s__('Pipelines|Description'), + }, + { + key: 'owner', + label: s__('Pipelines|Owner'), + }, + { + key: 'lastUsed', + label: s__('Pipelines|Last Used'), + }, + { + key: 'actions', + label: '', + tdClass: 'gl-text-right gl-white-space-nowrap', + }, + ], +}; +</script> + +<template> + <div> + <gl-table + v-if="triggers.length" + :fields="$options.fields" + :items="triggers" + class="triggers-list" + responsive + > + <template #cell(token)="{item}"> + {{ item.token }} + <clipboard-button + v-if="item.hasTokenExposed" + :text="item.token" + data-testid="clipboard-btn" + data-qa-selector="clipboard_button" + :title="s__('Pipelines|Copy trigger token')" + css-class="gl-border-none gl-py-0 gl-px-2" + /> + <div class="label-container"> + <gl-badge v-if="!item.canAccessProject" variant="danger"> + <span + v-gl-tooltip.viewport + boundary="viewport" + :title="s__('Pipelines|Trigger user has insufficient permissions to project')" + >{{ s__('Pipelines|invalid') }}</span + > + </gl-badge> + </div> + </template> + <template #cell(description)="{item}"> + <tooltip-on-truncate + :title="item.description" + truncate-target="child" + placement="top" + class="trigger-description gl-display-flex" + > + <div class="gl-flex-fill-1 gl-text-truncate">{{ item.description }}</div> + </tooltip-on-truncate> + </template> + <template #cell(owner)="{item}"> + <span class="trigger-owner sr-only">{{ item.owner.name }}</span> + <user-avatar-link + v-if="item.owner" + :link-href="item.owner.path" + :img-src="item.owner.avatarUrl" + :tooltip-text="item.owner.name" + :img-alt="item.owner.name" + /> + </template> + <template #cell(lastUsed)="{item}"> + <time-ago-tooltip v-if="item.lastUsed" :time="item.lastUsed" /> + <span v-else>{{ __('Never') }}</span> + </template> + <template #cell(actions)="{item}"> + <gl-button + :title="s__('Pipelines|Edit')" + icon="pencil" + data-testid="edit-btn" + :href="item.editProjectTriggerPath" + /> + <gl-button + :title="s__('Pipelines|Revoke')" + icon="remove" + variant="warning" + :data-confirm=" + s__( + 'Pipelines|By revoking a trigger you will break any processes making use of it. Are you sure?', + ) + " + data-method="delete" + rel="nofollow" + class="gl-ml-3" + data-testid="trigger_revoke_button" + data-qa-selector="trigger_revoke_button" + :href="item.projectTriggerPath" + /> + </template> + </gl-table> + <div + v-else + data-testid="no_triggers_content" + data-qa-selector="no_triggers_content" + class="settings-message gl-text-center gl-mb-3" + > + {{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/index.js b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js new file mode 100644 index 00000000000..182d5ca5ffb --- /dev/null +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import TriggersList from './components/triggers_list.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +const parseJsonArray = triggers => { + try { + return convertObjectPropsToCamelCase(JSON.parse(triggers), { deep: true }); + } catch { + return []; + } +}; + +export default (containerId = 'js-ci-pipeline-triggers-list') => { + const containerEl = document.getElementById(containerId); + + // Note: Remove this check when FF `ci_pipeline_triggers_settings_vue_ui` is removed. + if (!containerEl) { + return null; + } + + const triggers = parseJsonArray(containerEl.dataset.triggers); + + return new Vue({ + el: containerEl, + components: { + TriggersList, + }, + render(h) { + return h(TriggersList, { + props: { + triggers, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue index 0e09ae108ea..ceb94b1f0f8 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue @@ -1,22 +1,15 @@ <script> -import { - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlDeprecatedDropdownDivider, - GlSearchBoxByType, - GlIcon, -} from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { __, sprintf } from '~/locale'; export default { name: 'CiEnvironmentsDropdown', components: { - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlDeprecatedDropdownDivider, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, GlSearchBoxByType, - GlIcon, }, props: { value: { @@ -66,28 +59,25 @@ export default { }; </script> <template> - <gl-deprecated-dropdown :text="value"> - <gl-search-box-by-type v-model.trim="searchTerm" class="gl-m-3" /> - <gl-deprecated-dropdown-item + <gl-dropdown :text="value"> + <gl-search-box-by-type v-model.trim="searchTerm" /> + <gl-dropdown-item v-for="environment in filteredResults" :key="environment" + :is-checked="isSelected(environment)" + is-check-item @click="selectEnvironment(environment)" > - <gl-icon - :class="{ invisible: !isSelected(environment) }" - name="mobile-issue-close" - class="vertical-align-middle" - /> {{ environment }} - </gl-deprecated-dropdown-item> - <gl-deprecated-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{ + </gl-dropdown-item> + <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{ __('No matching results') - }}</gl-deprecated-dropdown-item> + }}</gl-dropdown-item> <template v-if="shouldRenderCreateButton"> - <gl-deprecated-dropdown-divider /> - <gl-deprecated-dropdown-item @click="createClicked"> + <gl-dropdown-divider /> + <gl-dropdown-item @click="createClicked"> {{ composedCreateButtonLabel }} - </gl-deprecated-dropdown-item> + </gl-dropdown-item> </template> - </gl-deprecated-dropdown> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index fbf19847e9d..a2f4bea2f61 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -6,7 +6,6 @@ import { GlFormCheckbox, GlFormCombobox, GlFormGroup, - GlFormInput, GlFormSelect, GlFormTextarea, GlIcon, @@ -41,7 +40,6 @@ export default { GlFormCheckbox, GlFormCombobox, GlFormGroup, - GlFormInput, GlFormSelect, GlFormTextarea, GlIcon, @@ -122,11 +120,6 @@ export default { return ''; }, tokenValidationState() { - // If the feature flag is off, do not validate. Remove when flag is removed. - if (!this.glFeatures.ciKeyAutocomplete) { - return true; - } - const validator = this.$options.tokens?.[this.variable.key]?.validation; if (validator) { @@ -204,21 +197,12 @@ export default { > <form> <gl-form-combobox - v-if="glFeatures.ciKeyAutocomplete" v-model="key" :token-list="$options.tokenList" :label-text="__('Key')" data-qa-selector="ci_variable_key_field" /> - <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key"> - <gl-form-input - id="ci-variable-key" - v-model="key" - data-qa-selector="ci_variable_key_field" - /> - </gl-form-group> - <gl-form-group :label="__('Value')" label-for="ci-variable-value" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index 501c82b419e..07278bb442c 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -163,10 +163,7 @@ export default { </p> </template> </gl-table> - <div - class="ci-variable-actions d-flex justify-content-end" - :class="{ 'justify-content-center': !tableIsNotEmpty }" - > + <div class="ci-variable-actions" :class="{ 'justify-content-center': !tableIsNotEmpty }"> <gl-button v-if="tableIsNotEmpty" ref="secret-value-reveal-button" diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 039237042ea..b03cf6fc31b 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -360,7 +360,7 @@ export default { > <template #link="{ content }"> <gl-link - href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html" + href="https://docs.gitlab.com/ee/user/project/integrations/prometheus.html" target="_blank" >{{ content }}</gl-link > @@ -481,7 +481,7 @@ export default { type="text" class="form-control js-hostname" /> - <span class="input-group-btn"> + <span class="input-group-append"> <clipboard-button :text="jupyterHostname" :title="s__('ClusterIntegration|Copy Jupyter Hostname')" diff --git a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue index c816fc56d7a..6b99bb09504 100644 --- a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue +++ b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue @@ -1,12 +1,12 @@ <script> -import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { s__ } from '../../locale'; export default { name: 'CrossplaneProviderStack', components: { - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownItem, GlIcon, }, props: { @@ -67,21 +67,17 @@ export default { <label> {{ s__('ClusterIntegration|Enabled stack') }} </label> - <gl-deprecated-dropdown + <gl-dropdown :disabled="crossplane.installed" :text="dropdownText" toggle-class="dropdown-menu-toggle gl-field-error-outline" class="w-100" :class="{ 'gl-show-field-errors': validationError }" > - <gl-deprecated-dropdown-item - v-for="stack in stacks" - :key="stack.code" - @click="selectStack(stack)" - > + <gl-dropdown-item v-for="stack in stacks" :key="stack.code" @click="selectStack(stack)"> <span class="ml-1">{{ stack.name }}</span> - </gl-deprecated-dropdown-item> - </gl-deprecated-dropdown> + </gl-dropdown-item> + </gl-dropdown> <span v-if="validationError" class="gl-field-error">{{ validationError }}</span> <p class="form-text text-muted"> {{ s__(`You must select a stack for configuring your cloud provider. Learn more about`) }} diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue index e6001b11296..b37fc3894f8 100644 --- a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue +++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue @@ -1,11 +1,5 @@ <script> -import { - GlAlert, - GlDeprecatedButton, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlFormCheckbox, -} from '@gitlab/ui'; +import { GlAlert, GlButton, GlDropdown, GlDropdownItem, GlFormCheckbox } from '@gitlab/ui'; import { mapValues } from 'lodash'; import { __ } from '~/locale'; import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants'; @@ -16,9 +10,9 @@ const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_S export default { components: { GlAlert, - GlDeprecatedButton, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlButton, + GlDropdown, + GlDropdownItem, GlFormCheckbox, }, props: { @@ -203,15 +197,15 @@ export default { <label for="fluentd-protocol"> <strong>{{ s__('ClusterIntegration|SIEM Protocol') }}</strong> </label> - <gl-deprecated-dropdown :text="protocolName" class="w-100"> - <gl-deprecated-dropdown-item + <gl-dropdown :text="protocolName" class="w-100"> + <gl-dropdown-item v-for="(value, index) in protocols" :key="index" @click="selectProtocol(value.toLowerCase())" > {{ value }} - </gl-deprecated-dropdown-item> - </gl-deprecated-dropdown> + </gl-dropdown-item> + </gl-dropdown> </div> <div class="form-group flex flex-wrap"> <gl-form-checkbox :checked="wafLogEnabled" @input="wafLogChanged"> @@ -221,20 +215,21 @@ export default { <strong>{{ s__('ClusterIntegration|Send Container Network Policies Logs') }}</strong> </gl-form-checkbox> </div> - <div v-if="showButtons" class="mt-3"> - <gl-deprecated-button + <div v-if="showButtons" class="gl-mt-5 gl-display-flex"> + <gl-button ref="saveBtn" - class="mr-1" + class="gl-mr-3" variant="success" + category="primary" :loading="isSaving" :disabled="saveButtonDisabled" @click="updateApplication" > {{ saveButtonLabel }} - </gl-deprecated-button> - <gl-deprecated-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus"> + </gl-button> + <gl-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus"> {{ __('Cancel') }} - </gl-deprecated-button> + </gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue index 5e8e1a76182..f05c8db5d56 100644 --- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue +++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue @@ -5,9 +5,9 @@ import { GlSprintf, GlLink, GlToggle, - GlDeprecatedButton, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlButton, + GlDropdown, + GlDropdownItem, GlIcon, } from '@gitlab/ui'; import modSecurityLogo from 'images/cluster_app_logos/gitlab.png'; @@ -25,9 +25,9 @@ export default { GlSprintf, GlLink, GlToggle, - GlDeprecatedButton, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlButton, + GlDropdown, + GlDropdownItem, GlIcon, }, props: { @@ -221,29 +221,31 @@ export default { </strong> </p> </div> - <gl-deprecated-dropdown :text="modSecurityModeName" :disabled="saveButtonDisabled"> - <gl-deprecated-dropdown-item - v-for="(mode, key) in modes" - :key="key" - @click="selectMode(key)" - > + <gl-dropdown :text="modSecurityModeName" :disabled="saveButtonDisabled"> + <gl-dropdown-item v-for="(mode, key) in modes" :key="key" @click="selectMode(key)"> {{ mode.name }} - </gl-deprecated-dropdown-item> - </gl-deprecated-dropdown> + </gl-dropdown-item> + </gl-dropdown> </div> </div> - <div v-if="showButtons" class="mt-3"> - <gl-deprecated-button - class="btn-success inline mr-1" + <div v-if="showButtons" class="gl-mt-5 gl-display-flex"> + <gl-button + variant="success" + category="primary" + data-qa-selector="save_ingress_modsecurity_settings" :loading="saving" :disabled="saveButtonDisabled" @click="updateApplication" > {{ saveButtonLabel }} - </gl-deprecated-button> - <gl-deprecated-button :disabled="saveButtonDisabled" @click="resetStatus"> + </gl-button> + <gl-button + data-qa-selector="cancel_ingress_modsecurity_settings" + :disabled="saveButtonDisabled" + @click="resetStatus" + > {{ __('Cancel') }} - </gl-deprecated-button> + </gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue index 2617ea0bdea..cb415d902e8 100644 --- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -1,8 +1,8 @@ <script> import { - GlDeprecatedDropdown, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, GlLoadingIcon, GlSearchBoxByType, GlSprintf, @@ -20,9 +20,9 @@ export default { GlButton, ClipboardButton, GlLoadingIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, GlSearchBoxByType, GlSprintf, }, @@ -121,7 +121,7 @@ export default { <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong> </label> - <gl-deprecated-dropdown + <gl-dropdown v-if="showDomainsDropdown" :text="domainDropdownText" toggle-class="dropdown-menu-toggle" @@ -130,18 +130,17 @@ export default { <gl-search-box-by-type v-model.trim="searchQuery" :placeholder="s__('ClusterIntegration|Search domains')" - class="gl-m-3" /> - <gl-deprecated-dropdown-item + <gl-dropdown-item v-for="domain in filteredDomains" :key="domain.id" @click="selectDomain(domain)" > <span class="ml-1">{{ domain.domain }}</span> - </gl-deprecated-dropdown-item> + </gl-dropdown-item> <template v-if="searchQuery"> - <gl-deprecated-dropdown-divider /> - <gl-deprecated-dropdown-item key="custom-domain" @click="selectCustomDomain(searchQuery)"> + <gl-dropdown-divider /> + <gl-dropdown-item key="custom-domain" @click="selectCustomDomain(searchQuery)"> <span class="ml-1"> <gl-sprintf :message="s__('ClusterIntegration|Use %{query}')"> <template #query> @@ -149,9 +148,9 @@ export default { </template> </gl-sprintf> </span> - </gl-deprecated-dropdown-item> + </gl-dropdown-item> </template> - </gl-deprecated-dropdown> + </gl-dropdown> <input v-else diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue index f82f4dd5012..477dd13db4f 100644 --- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue +++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue @@ -1,6 +1,5 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlModal } from '@gitlab/ui'; +import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click'; import { sprintf, s__ } from '~/locale'; import { @@ -45,6 +44,9 @@ export default { components: { GlModal, }, + directives: { + SafeHtml, + }, mixins: [trackUninstallButtonClickMixin], props: { application: { @@ -94,6 +96,6 @@ export default { :title="title" @ok="confirmUninstall()" > - {{ warningText }} <span v-html="customAppWarningText"></span> + {{ warningText }} <span v-safe-html="customAppWarningText"></span> </gl-modal> </template> diff --git a/app/assets/javascripts/clusters/forms/components/integration_form.vue b/app/assets/javascripts/clusters/forms/components/integration_form.vue index 53e004b4fc0..f0dafa7ef53 100644 --- a/app/assets/javascripts/clusters/forms/components/integration_form.vue +++ b/app/assets/javascripts/clusters/forms/components/integration_form.vue @@ -24,10 +24,10 @@ export default { }, inject: { autoDevopsHelpPath: { - type: String, + default: '', }, externalEndpointHelpPath: { - type: String, + default: '', }, }, data() { diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 7b53020fc49..f8fb58cdca2 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -10,6 +10,7 @@ import { GlTable, } from '@gitlab/ui'; import AncestorNotice from './ancestor_notice.vue'; +import NodeErrorHelpText from './node_error_help_text.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import { CLUSTER_TYPES, STATUSES } from '../constants'; import { __, sprintf } from '~/locale'; @@ -26,6 +27,7 @@ export default { GlSkeletonLoading, GlSprintf, GlTable, + NodeErrorHelpText, }, directives: { tooltip, @@ -199,7 +201,13 @@ export default { <section v-else> <ancestor-notice /> - <gl-table :items="clusters" :fields="fields" stacked="md" class="qa-clusters-table"> + <gl-table + :items="clusters" + :fields="fields" + stacked="md" + class="qa-clusters-table" + data-testid="cluster_list_table" + > <template #cell(name)="{ item }"> <div :class="[contentAlignClasses, 'js-status']"> <img @@ -231,9 +239,12 @@ export default { <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> - <small v-else class="gl-font-sm gl-font-style-italic gl-text-gray-200">{{ - __('Unknown') - }}</small> + <NodeErrorHelpText + v-else-if="item.kubernetes_errors" + :class="contentAlignClasses" + :error-type="item.kubernetes_errors.connection_error" + :popover-id="`nodeSizeError${item.id}`" + /> </template> <template #cell(total_cpu)="{ item }"> @@ -250,6 +261,13 @@ export default { </span> <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> + + <NodeErrorHelpText + v-else-if="item.kubernetes_errors" + :class="contentAlignClasses" + :error-type="item.kubernetes_errors.node_connection_error" + :popover-id="`nodeCpuError${item.id}`" + /> </template> <template #cell(total_memory)="{ item }"> @@ -266,6 +284,13 @@ export default { </span> <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> + + <NodeErrorHelpText + v-else-if="item.kubernetes_errors" + :class="contentAlignClasses" + :error-type="item.kubernetes_errors.metrics_connection_error" + :popover-id="`nodeMemoryError${item.id}`" + /> </template> <template #cell(cluster_type)="{value}"> diff --git a/app/assets/javascripts/clusters_list/components/node_error_help_text.vue b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue new file mode 100644 index 00000000000..1a396694bc8 --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue @@ -0,0 +1,53 @@ +<script> +import { GlIcon, GlPopover } from '@gitlab/ui'; +import { CLUSTER_ERRORS } from '../constants'; + +export default { + components: { + GlIcon, + GlPopover, + }, + props: { + errorType: { + type: String, + required: false, + default: '', + }, + popoverId: { + type: String, + required: true, + }, + }, + computed: { + errorContent() { + return CLUSTER_ERRORS[this.errorType] || CLUSTER_ERRORS.default; + }, + }, +}; +</script> + +<template> + <div :id="popoverId"> + <span class="gl-font-style-italic"> + {{ errorContent.tableText }} + </span> + + <gl-icon name="status_warning" :size="24" class="gl-p-2" /> + + <gl-popover :container="popoverId" :target="popoverId" placement="top" triggers="hover focus"> + <template #title> + <span class="gl-display-block gl-text-left">{{ errorContent.title }}</span> + </template> + + <p class="gl-text-left">{{ errorContent.description }}</p> + + <p class="gl-text-left">{{ s__('ClusterIntegration|Troubleshooting tips:') }}</p> + + <ul class="gl-text-left"> + <li v-for="tip in errorContent.troubleshootingTips" :key="tip"> + {{ tip }} + </li> + </ul> + </gl-popover> + </div> +</template> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 3e8ef3151a6..f39678b73dc 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -1,4 +1,45 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; + +export const CLUSTER_ERRORS = { + default: { + tableText: s__('ClusterIntegration|Unknown Error'), + title: s__('ClusterIntegration|Unknown Error'), + description: s__( + 'ClusterIntegration|An unknown error occurred while attempting to connect to Kubernetes.', + ), + troubleshootingTips: [ + s__('ClusterIntegration|Check your cluster status'), + s__('ClusterIntegration|Make sure your API endpoint is correct'), + s__( + 'ClusterIntegration|Node calculations use the Kubernetes Metrics API. Make sure your cluster has metrics installed', + ), + ], + }, + authentication_error: { + tableText: s__('ClusterIntegration|Unable to Authenticate'), + title: s__('ClusterIntegration|Authentication Error'), + description: s__('ClusterIntegration|GitLab failed to authenticate.'), + troubleshootingTips: [ + s__('ClusterIntegration|Check your token'), + s__('ClusterIntegration|Check your CA certificate'), + ], + }, + connection_error: { + tableText: s__('ClusterIntegration|Unable to Connect'), + title: s__('ClusterIntegration|Connection Error'), + description: s__('ClusterIntegration|GitLab failed to connect to the cluster.'), + troubleshootingTips: [ + s__('ClusterIntegration|Check your cluster status'), + s__('ClusterIntegration|Make sure your API endpoint is correct'), + ], + }, + http_error: { + tableText: s__('ClusterIntegration|Unable to Connect'), + title: s__('ClusterIntegration|HTTP Error'), + description: s__('ClusterIntegration|There was an HTTP error when connecting to your cluster.'), + troubleshootingTips: [s__('ClusterIntegration|Check your cluster status')], + }, +}; export const CLUSTER_TYPES = { project_type: __('Project'), diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js index 51ad8769250..daa82892773 100644 --- a/app/assets/javascripts/clusters_list/index.js +++ b/app/assets/javascripts/clusters_list/index.js @@ -1,20 +1,6 @@ import Vue from 'vue'; -import Clusters from './components/clusters.vue'; -import { createStore } from './store'; +import loadClusters from './load_clusters'; export default () => { - const entryPoint = document.querySelector('#js-clusters-list-app'); - - if (!entryPoint) { - return; - } - - // eslint-disable-next-line no-new - new Vue({ - el: '#js-clusters-list-app', - store: createStore(entryPoint.dataset), - render(createElement) { - return createElement(Clusters); - }, - }); + loadClusters(Vue); }; diff --git a/app/assets/javascripts/clusters_list/load_clusters.js b/app/assets/javascripts/clusters_list/load_clusters.js new file mode 100644 index 00000000000..98bc5880898 --- /dev/null +++ b/app/assets/javascripts/clusters_list/load_clusters.js @@ -0,0 +1,18 @@ +import Clusters from './components/clusters.vue'; +import { createStore } from './store'; + +export default Vue => { + const el = document.querySelector('#js-clusters-list-app'); + + if (!el) { + return null; + } + + return new Vue({ + el, + store: createStore(el.dataset), + render(createElement) { + return createElement(Clusters); + }, + }); +}; diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index ff711877621..1be82988db0 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/wrapper'; import Poll from '~/lib/utils/poll'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from '~/flash'; diff --git a/app/assets/javascripts/code_navigation/index.js b/app/assets/javascripts/code_navigation/index.js index 362c26ae065..fa5835245bc 100644 --- a/app/assets/javascripts/code_navigation/index.js +++ b/app/assets/javascripts/code_navigation/index.js @@ -1,13 +1,17 @@ import Vue from 'vue'; import Vuex from 'vuex'; -import store from './store'; +import createStore from './store'; import App from './components/app.vue'; -Vue.use(Vuex); - export default initialData => { const el = document.getElementById('js-code-navigation'); + if (!el) return null; + + Vue.use(Vuex); + + const store = createStore(); + store.dispatch('setInitialData', initialData); return new Vue({ diff --git a/app/assets/javascripts/code_navigation/store/index.js b/app/assets/javascripts/code_navigation/store/index.js index fe48f3ac7f5..9b60fc337fe 100644 --- a/app/assets/javascripts/code_navigation/store/index.js +++ b/app/assets/javascripts/code_navigation/store/index.js @@ -3,8 +3,9 @@ import createState from './state'; import actions from './actions'; import mutations from './mutations'; -export default new Vuex.Store({ - actions, - mutations, - state: createState(), -}); +export default () => + new Vuex.Store({ + actions, + mutations, + state: createState(), + }); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js index 340a93e4e66..c8168afbcb0 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -3,9 +3,10 @@ import commitPipelinesTable from './pipelines_table.vue'; /** * Used in: - * - Commit details View > Pipelines Tab > Pipelines Table. - * - Merge Request details View > Pipelines Tab > Pipelines Table. - * - New Merge Request View > Pipelines Tab > Pipelines Table. + * - Project Pipelines List (projects:pipelines:index) + * - Commit details View > Pipelines Tab > Pipelines Table (projects:commit:pipelines) + * - Merge Request details View > Pipelines Tab > Pipelines Table (projects:merge_requests:show) + * - New Merge Request View > Pipelines Tab > Pipelines Table (projects:merge_requests:creations:new) */ const CommitPipelinesTable = Vue.extend(commitPipelinesTable); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 2f4118c1717..fe32868e6d8 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui'; -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import PipelinesService from '~/pipelines/services/pipelines_service'; import PipelineStore from '~/pipelines/stores/pipelines_store'; import pipelinesMixin from '~/pipelines/mixins/pipelines'; @@ -126,16 +125,6 @@ export default { (latest.flags.detached_merge_request_pipeline || latest.flags.merge_request_pipeline) ); }, - /** - * When we are on Desktop and the button is visible - * we need to add a negative margin to the table - * to make it inline with the button - * - * @returns {Boolean} - */ - shouldAddNegativeMargin() { - return this.canRenderPipelineButton && bp.isDesktop(); - }, }, created() { this.service = new PipelinesService(this.endpoint); @@ -204,66 +193,77 @@ export default { " /> - <div v-else-if="shouldRenderTable" class="table-holder"> - <div v-if="canRenderPipelineButton" class="nav justify-content-end"> - <gl-button - variant="success" - class="js-run-mr-pipeline gl-mt-3 btn-wide-on-xs" - :disabled="state.isRunningMergeRequestPipeline" - @click="tryRunPipeline" - > - <gl-loading-icon v-if="state.isRunningMergeRequestPipeline" inline /> - {{ s__('Pipelines|Run Pipeline') }} - </gl-button> - - <gl-modal - :id="modalId" - ref="modal" - :modal-id="modalId" - :title="s__('Pipelines|Are you sure you want to run this pipeline?')" - :ok-title="s__('Pipelines|Run Pipeline')" - ok-variant="danger" - @ok="onClickRunPipeline" - > - <p> - {{ - s__( - 'Pipelines|This pipeline will run code originating from a forked project merge request. This means that the code can potentially have security considerations like exposing CI variables.', - ) - }} - </p> - <p> - {{ - s__( - "Pipelines|It is recommended the code is reviewed thoroughly before running this pipeline with the parent project's CI resource.", - ) - }} - </p> - <p> - {{ - s__( - 'Pipelines|If you are unsure, please ask a project maintainer to review it for you.', - ) - }} - </p> - <gl-link - href="/help/ci/merge_request_pipelines/index.html#run-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project" - target="_blank" - > - {{ s__('Pipelines|More Information') }} - </gl-link> - </gl-modal> - </div> + <div v-else-if="shouldRenderTable"> + <gl-button + v-if="canRenderPipelineButton" + block + class="gl-mt-3 gl-mb-0 gl-display-md-none" + variant="success" + data-testid="run_pipeline_button_mobile" + :loading="state.isRunningMergeRequestPipeline" + @click="tryRunPipeline" + > + {{ s__('Pipelines|Run Pipeline') }} + </gl-button> <pipelines-table-component :pipelines="state.pipelines" :update-graph-dropdown="updateGraphDropdown" :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" - :class="{ 'negative-margin-top': shouldAddNegativeMargin }" - /> + > + <template #table-header-actions> + <div v-if="canRenderPipelineButton" class="gl-text-right"> + <gl-button + variant="success" + data-testid="run_pipeline_button" + :loading="state.isRunningMergeRequestPipeline" + @click="tryRunPipeline" + > + {{ s__('Pipelines|Run Pipeline') }} + </gl-button> + </div> + </template> + </pipelines-table-component> </div> + <gl-modal + v-if="canRenderPipelineButton" + :id="modalId" + ref="modal" + :modal-id="modalId" + :title="s__('Pipelines|Are you sure you want to run this pipeline?')" + :ok-title="s__('Pipelines|Run Pipeline')" + ok-variant="danger" + @ok="onClickRunPipeline" + > + <p> + {{ + s__( + 'Pipelines|This pipeline will run code originating from a forked project merge request. This means that the code can potentially have security considerations like exposing CI variables.', + ) + }} + </p> + <p> + {{ + s__( + "Pipelines|It is recommended the code is reviewed thoroughly before running this pipeline with the parent project's CI resource.", + ) + }} + </p> + <p> + {{ + s__('Pipelines|If you are unsure, please ask a project maintainer to review it for you.') + }} + </p> + <gl-link + href="/help/ci/merge_request_pipelines/index.html#run-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project" + target="_blank" + > + {{ s__('Pipelines|More Information') }} + </gl-link> + </gl-modal> + <table-pagination v-if="shouldRenderPagination" :change="onChangePage" diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index e0d012cef23..77c85d85e27 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -1,5 +1,4 @@ import './polyfills'; -import './jquery'; import './bootstrap'; import './vue'; import '../lib/utils/axios_utils'; diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js deleted file mode 100644 index 334f95bb27f..00000000000 --- a/app/assets/javascripts/commons/jquery.js +++ /dev/null @@ -1,4 +0,0 @@ -import 'jquery'; - -// common jQuery plugins -import 'jquery-ujs'; diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue index 5b4bdca46e4..6bb654a434f 100644 --- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue +++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue @@ -1,12 +1,11 @@ <script> -import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { __ } from '~/locale'; export default { components: { - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlIcon, + GlDropdown, + GlDropdownItem, }, props: { projects: { @@ -37,25 +36,15 @@ export default { </script> <template> - <gl-deprecated-dropdown toggle-class="d-flex align-items-center w-100" class="w-100"> - <template #button-content> - <span class="str-truncated-100 mr-2"> - <gl-icon name="lock" /> - {{ dropdownText }} - </span> - <gl-icon name="chevron-down" class="ml-auto" /> - </template> - <gl-deprecated-dropdown-item + <gl-dropdown block icon="lock" :text="dropdownText"> + <gl-dropdown-item v-for="project in projects" :key="project.id" + :is-check-item="true" + :is-checked="project.id === selectedProject.id" @click="selectProject(project)" > - <gl-icon - name="mobile-issue-close" - :class="{ icon: project.id !== selectedProject.id }" - class="js-active-project-check" - /> - <span class="ml-1">{{ project.name }}</span> - </gl-deprecated-dropdown-item> - </gl-deprecated-dropdown> + {{ project.name }} + </gl-dropdown-item> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js index 262d501bfba..7321e4d18cc 100644 --- a/app/assets/javascripts/confirm_danger_modal.js +++ b/app/assets/javascripts/confirm_danger_modal.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { Rails } from '~/lib/utils/rails_ujs'; import { rstrip } from './lib/utils/common_utils'; function openConfirmDangerModal($form, $modal, text) { @@ -21,9 +22,16 @@ function openConfirmDangerModal($form, $modal, text) { $submit.disable(); } }); + $('.js-confirm-danger-submit', $modal) .off('click') - .on('click', () => $form.submit()); + .on('click', () => { + if ($form.data('remote')) { + Rails.fire($form[0], 'submit'); + } else { + $form.submit(); + } + }); } function getModal($btn) { diff --git a/app/assets/javascripts/confirm_modal.js b/app/assets/javascripts/confirm_modal.js index 4b4fdf03873..bf2ea3ce38a 100644 --- a/app/assets/javascripts/confirm_modal.js +++ b/app/assets/javascripts/confirm_modal.js @@ -1,14 +1,16 @@ import Vue from 'vue'; import ConfirmModal from '~/vue_shared/components/confirm_modal.vue'; -const mountConfirmModal = () => { - return new Vue({ +const mountConfirmModal = optionalProps => + new Vue({ render(h) { return h(ConfirmModal, { - props: { selector: '.js-confirm-modal-button' }, + props: { + selector: '.js-confirm-modal-button', + ...optionalProps, + }, }); }, }).$mount(); -}; -export default () => mountConfirmModal(); +export default (optionalProps = {}) => mountConfirmModal(optionalProps); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue index 3f7c2204b9f..eb195ad2b30 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue @@ -13,6 +13,10 @@ export default { type: String, required: true, }, + namespacePerEnvironmentHelpPath: { + type: String, + required: true, + }, kubernetesIntegrationHelpPath: { type: String, required: true, @@ -40,6 +44,7 @@ export default { <eks-cluster-configuration-form v-if="hasCredentials" :gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath" + :namespace-per-environment-help-path="namespacePerEnvironmentHelpPath" :kubernetes-integration-help-path="kubernetesIntegrationHelpPath" :external-link-icon="externalLinkIcon" /> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index a653e228e3f..d403f370f9d 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -1,9 +1,7 @@ <script> -/* eslint-disable vue/no-v-html */ import { createNamespacedHelpers, mapState, mapActions, mapGetters } from 'vuex'; -import { escape } from 'lodash'; -import { GlFormInput, GlFormCheckbox } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; +import { GlFormInput, GlFormCheckbox, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; import { KUBERNETES_VERSIONS } from '../constants'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; @@ -28,8 +26,11 @@ const { mapState: mapInstanceTypesState } = createNamespacedHelpers('instanceTyp export default { components: { ClusterFormDropdown, - GlFormInput, GlFormCheckbox, + GlFormInput, + GlIcon, + GlLink, + GlSprintf, LoadingButton, }, props: { @@ -37,6 +38,10 @@ export default { type: String, required: true, }, + namespacePerEnvironmentHelpPath: { + type: String, + required: true, + }, kubernetesIntegrationHelpPath: { type: String, required: true, @@ -46,6 +51,49 @@ export default { required: true, }, }, + i18n: { + kubernetesIntegrationHelpText: s__( + 'ClusterIntegration|Read our %{linkStart}help page%{linkEnd} on Kubernetes cluster integration.', + ), + roleDropdownHelpText: s__( + 'ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{linkStart}Amazon Web Services%{linkEnd}.', + ), + roleDropdownHelpPath: + 'https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html#create-service-role', + regionsDropdownHelpText: s__( + 'ClusterIntegration|Learn more about %{linkStart}Regions%{linkEnd}.', + ), + regionsDropdownHelpPath: + 'https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/', + keyPairDropdownHelpText: s__( + 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{linkStart}Amazon Web Services%{linkEnd}.', + ), + keyPairDropdownHelpPath: + 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair', + vpcDropdownHelpText: s__( + 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{linkStart}Amazon Web Services %{linkEnd}.', + ), + vpcDropdownHelpPath: + 'https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create', + subnetDropdownHelpText: s__( + 'ClusterIntegration|Choose the %{linkStart}subnets %{linkEnd} in your VPC where your worker nodes will run.', + ), + subnetDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#subnets', + securityGroupDropdownHelpText: s__( + 'ClusterIntegration|Choose the %{linkStart}security group %{linkEnd} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', + ), + securityGroupDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#securityGroups', + instanceTypesDropdownHelpText: s__( + 'ClusterIntegration|Choose the worker node %{linkStart}instance type%{linkEnd}.', + ), + instanceTypesDropdownHelpPath: 'https://aws.amazon.com/ec2/instance-types', + gitlabManagedClusterHelpText: s__( + 'ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{linkStart}More information%{linkEnd}', + ), + namespacePerEnvironmentHelpText: s__( + 'ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared. %{linkStart}More information%{linkEnd}', + ), + }, computed: { ...mapState([ 'clusterName', @@ -60,6 +108,7 @@ export default { 'selectedInstanceType', 'nodeCount', 'gitlabManagedCluster', + 'namespacePerEnvironment', 'isCreatingCluster', ]), ...mapGetters(['subnetValid']), @@ -137,90 +186,6 @@ export default { ? s__('ClusterIntegration|Creating Kubernetes cluster') : s__('ClusterIntegration|Create Kubernetes cluster'); }, - kubernetesIntegrationHelpText() { - const escapedUrl = escape(this.kubernetesIntegrationHelpPath); - - return sprintf( - s__( - 'ClusterIntegration|Read our %{link_start}help page%{link_end} on Kubernetes cluster integration.', - ), - { - link_start: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, - link_end: '</a>', - }, - false, - ); - }, - roleDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', - ), - { - startLink: - '<a href="https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html#create-service-role" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, - regionsDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Learn more about %{startLink}Regions %{externalLinkIcon}%{endLink}.', - ), - { - startLink: - '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, - keyPairDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', - ), - { - startLink: - '<a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, - vpcDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', - ), - { - startLink: - '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, - subnetDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Choose the %{startLink}subnets %{externalLinkIcon} %{endLink} in your VPC where your worker nodes will run.', - ), - { - startLink: - '<a href="https://console.aws.amazon.com/vpc/home?#subnets" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, subnetValidationErrorText() { if (this.loadingSubnetsError) { return s__('ClusterIntegration|Could not load subnets for the selected VPC'); @@ -228,48 +193,6 @@ export default { return s__('ClusterIntegration|You should select at least two subnets'); }, - securityGroupDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Choose the %{startLink}security group %{externalLinkIcon} %{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', - ), - { - startLink: - '<a href="https://console.aws.amazon.com/vpc/home?#securityGroups" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, - instanceTypesDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Choose the worker node %{startLink}instance type %{externalLinkIcon} %{endLink}.', - ), - { - startLink: - '<a href="https://aws.amazon.com/ec2/instance-types" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, - gitlabManagedHelpText() { - const escapedUrl = escape(this.gitlabManagedClusterHelpPath); - - return sprintf( - s__( - 'ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{startLink}More information%{endLink}', - ), - { - startLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, - endLink: '</a>', - }, - false, - ); - }, }, mounted() { this.fetchRegions(); @@ -290,6 +213,7 @@ export default { 'setInstanceType', 'setNodeCount', 'setGitlabManagedCluster', + 'setNamespacePerEnvironment', ]), ...mapRegionsActions({ fetchRegions: 'fetchItems' }), ...mapVpcActions({ fetchVpcs: 'fetchItems' }), @@ -321,7 +245,15 @@ export default { <h4> {{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }} </h4> - <div class="mb-3" v-html="kubernetesIntegrationHelpText"></div> + <div class="mb-3"> + <gl-sprintf :message="$options.i18n.kubernetesIntegrationHelpText"> + <template #link="{ content }"> + <gl-link :href="kubernetesIntegrationHelpPath"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> <div class="form-group"> <label class="label-bold" for="eks-cluster-name">{{ s__('ClusterIntegration|Kubernetes cluster name') @@ -371,7 +303,16 @@ export default { :error-message="s__('ClusterIntegration|Could not load IAM roles')" @input="setRole({ role: $event })" /> - <p class="form-text text-muted" v-html="roleDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.roleDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.roleDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label> @@ -389,7 +330,16 @@ export default { :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')" @input="setRegionAndFetchVpcsAndKeyPairs($event)" /> - <p class="form-text text-muted" v-html="regionsDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.regionsDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.regionsDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-key-pair">{{ @@ -411,7 +361,16 @@ export default { :error-message="s__('ClusterIntegration|Could not load Key Pairs')" @input="setKeyPair({ keyPair: $event })" /> - <p class="form-text text-muted" v-html="keyPairDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.keyPairDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.keyPairDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-vpc">{{ s__('ClusterIntegration|VPC') }}</label> @@ -431,7 +390,16 @@ export default { :error-message="s__('ClusterIntegration|Could not load VPCs for the selected region')" @input="setVpcAndFetchSubnets($event)" /> - <p class="form-text text-muted" v-html="vpcDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.vpcDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.vpcDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnets') }}</label> @@ -452,7 +420,16 @@ export default { :error-message="subnetValidationErrorText" @input="setSubnet({ subnet: $event })" /> - <p class="form-text text-muted" v-html="subnetDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.subnetDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.subnetDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-security-group">{{ @@ -476,7 +453,16 @@ export default { " @input="setSecurityGroup({ securityGroup: $event })" /> - <p class="form-text text-muted" v-html="securityGroupDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.securityGroupDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.securityGroupDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-instance-type">{{ @@ -496,7 +482,16 @@ export default { :error-message="s__('ClusterIntegration|Could not load instance types')" @input="setInstanceType({ instanceType: $event })" /> - <p class="form-text text-muted" v-html="instanceTypesDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.instanceTypesDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.instanceTypesDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-node-count">{{ @@ -517,7 +512,31 @@ export default { @input="setGitlabManagedCluster({ gitlabManagedCluster: $event })" >{{ s__('ClusterIntegration|GitLab-managed cluster') }}</gl-form-checkbox > - <p class="form-text text-muted" v-html="gitlabManagedHelpText"></p> + <p class="form text text-muted"> + <gl-sprintf :message="$options.i18n.gitlabManagedClusterHelpText"> + <template #link="{ content }"> + <gl-link :href="gitlabManagedClusterHelpPath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </div> + <div class="form-group"> + <gl-form-checkbox + :checked="namespacePerEnvironment" + @input="setNamespacePerEnvironment({ namespacePerEnvironment: $event })" + >{{ s__('ClusterIntegration|Namespace per environment') }}</gl-form-checkbox + > + <p class="form text text-muted"> + <gl-sprintf :message="$options.i18n.namespacePerEnvironmentHelpText"> + <template #link="{ content }"> + <gl-link :href="namespacePerEnvironmentHelpPath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <loading-button diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js index fb993a7aa59..6d1034b4a72 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js @@ -9,6 +9,7 @@ Vue.use(Vuex); export default el => { const { gitlabManagedClusterHelpPath, + namespacePerEnvironmentHelpPath, kubernetesIntegrationHelpPath, accountAndExternalIdsHelpPath, createRoleArnHelpPath, @@ -42,6 +43,7 @@ export default el => { return createElement('create-eks-cluster', { props: { gitlabManagedClusterHelpPath, + namespacePerEnvironmentHelpPath, kubernetesIntegrationHelpPath, accountAndExternalIdsHelpPath, createRoleArnHelpPath, diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index 5abff3c7831..48c85ff627f 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -55,6 +55,7 @@ export const createCluster = ({ dispatch, state }) => { name: state.clusterName, environment_scope: state.environmentScope, managed: state.gitlabManagedCluster, + namespace_per_environment: state.namespacePerEnvironment, provider_aws_attributes: { kubernetes_version: state.kubernetesVersion, region: state.selectedRegion, @@ -114,6 +115,10 @@ export const setGitlabManagedCluster = ({ commit }, payload) => { commit(types.SET_GITLAB_MANAGED_CLUSTER, payload); }; +export const setNamespacePerEnvironment = ({ commit }, payload) => { + commit(types.SET_NAMESPACE_PER_ENVIRONMENT, payload); +}; + export const setInstanceType = ({ commit }, payload) => { commit(types.SET_INSTANCE_TYPE, payload); }; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js index 9dee6abae5f..4a48195a27b 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js @@ -10,6 +10,7 @@ export const SET_SECURITY_GROUP = 'SET_SECURITY_GROUP'; export const SET_INSTANCE_TYPE = 'SET_INSTANCE_TYPE'; export const SET_NODE_COUNT = 'SET_NODE_COUNT'; export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER'; +export const SET_NAMESPACE_PER_ENVIRONMENT = 'SET_NAMESPACE_PER_ENVIRONMENT'; export const REQUEST_CREATE_ROLE = 'REQUEST_CREATE_ROLE'; export const CREATE_ROLE_SUCCESS = 'CREATE_ROLE_SUCCESS'; export const CREATE_ROLE_ERROR = 'CREATE_ROLE_ERROR'; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js index c331d27d255..f57236e0e31 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js @@ -37,6 +37,9 @@ export default { [types.SET_GITLAB_MANAGED_CLUSTER](state, { gitlabManagedCluster }) { state.gitlabManagedCluster = gitlabManagedCluster; }, + [types.SET_NAMESPACE_PER_ENVIRONMENT](state, { namespacePerEnvironment }) { + state.namespacePerEnvironment = namespacePerEnvironment; + }, [types.REQUEST_CREATE_ROLE](state) { state.isCreatingRole = true; state.createRoleError = null; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js index ed51e95e434..c957eca1f7a 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js @@ -30,4 +30,5 @@ export default () => ({ createClusterError: false, gitlabManagedCluster: true, + namespacePerEnvironment: true, }); diff --git a/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue b/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue index 34e4aeb290f..7c4117d7e8b 100644 --- a/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue +++ b/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue @@ -1,11 +1,11 @@ <script> -import { GlModal, GlModalDirective, GlDeprecatedButton } from '@gitlab/ui'; +import { GlModal, GlModalDirective, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; export default { components: { GlModal, - GlDeprecatedButton, + GlButton, }, directives: { 'gl-modal': GlModalDirective, @@ -33,9 +33,9 @@ export default { </script> <template> <div class="d-inline-block float-right mr-3"> - <gl-deprecated-button v-gl-modal="$options.modalId" variant="danger"> + <gl-button v-gl-modal="$options.modalId" variant="danger" category="primary"> {{ __('Delete') }} - </gl-deprecated-button> + </gl-button> <gl-modal :title="s__('Metrics|Delete metric?')" :ok-title="s__('Metrics|Delete metric')" diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue index ff0f352b333..b2c9cd4e597 100644 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue @@ -1,7 +1,10 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; export default { + components: { + GlIcon, + }, directives: { GlTooltip: GlTooltipDirective, }, @@ -15,15 +18,17 @@ export default { </script> <template> <span v-if="count === 50" class="events-info float-right"> - <i - v-gl-tooltip - :title=" - n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50) - " - class="fa fa-warning" + <gl-icon + v-gl-tooltip="{ + title: n__( + 'Limited to showing %d event at most', + 'Limited to showing %d events at most', + 50, + ), + }" + name="warning" aria-hidden="true" - > - </i> + /> {{ n__('Showing %d event', 'Showing %d events', 50) }} </span> </template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue index ba2be2e5167..6d8f711c13b 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue @@ -1,8 +1,6 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; -import iconBranch from '../svg/icon_branch.svg'; import limitWarning from './limit_warning_component.vue'; import totalTime from './total_time_component.vue'; @@ -13,6 +11,9 @@ export default { limitWarning, GlIcon, }, + directives: { + SafeHtml, + }, props: { items: { type: Array, @@ -25,11 +26,6 @@ export default { required: false, }, }, - computed: { - iconBranch() { - return iconBranch; - }, - }, }; </script> <template> @@ -47,7 +43,9 @@ export default { <a :href="build.url" class="pipeline-id"> #{{ build.id }} </a> <gl-icon :size="16" name="fork" /> <a :href="build.branch.url" class="ref-name"> {{ build.branch.name }} </a> - <span class="icon-branch" v-html="iconBranch"> </span> + <span class="icon-branch gl-text-gray-400"> + <gl-icon name="commit" :size="14" /> + </span> <a :href="build.commitUrl" class="commit-sha"> {{ build.shortSha }} </a> </h5> <span> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue index cd49b3c5222..c165c8cee78 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue @@ -1,8 +1,5 @@ <script> -/* eslint-disable vue/no-v-html */ import { GlIcon } from '@gitlab/ui'; -import iconBuildStatus from '../svg/icon_build_status.svg'; -import iconBranch from '../svg/icon_branch.svg'; import limitWarning from './limit_warning_component.vue'; import totalTime from './total_time_component.vue'; @@ -24,14 +21,6 @@ export default { required: false, }, }, - computed: { - iconBuildStatus() { - return iconBuildStatus; - }, - iconBranch() { - return iconBranch; - }, - }, }; </script> <template> @@ -44,12 +33,16 @@ export default { <li v-for="(build, i) in items" :key="i" class="stage-event-item item-build-component"> <div class="item-details"> <h5 class="item-title"> - <span class="icon-build-status" v-html="iconBuildStatus"> </span> + <span class="icon-build-status gl-text-green-500"> + <gl-icon name="status_success" :size="14" /> + </span> <a :href="build.url" class="item-build-name"> {{ build.name }} </a> · <a :href="build.url" class="pipeline-id"> #{{ build.id }} </a> <gl-icon :size="16" name="fork" /> <a :href="build.branch.url" class="ref-name"> {{ build.branch.name }} </a> - <span class="icon-branch" v-html="iconBranch"> </span> + <span class="icon-branch gl-text-gray-400"> + <gl-icon name="commit" :size="14" /> + </span> <a :href="build.commitUrl" class="commit-sha"> {{ build.shortSha }} </a> </h5> <span> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index f6bad5dce41..4cccabca28b 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -36,12 +36,6 @@ export default () => { 'stage-review-component': stageReviewComponent, 'stage-staging-component': stageStagingComponent, 'stage-production-component': stageComponent, - GroupsDropdownFilter: () => - import('ee_component/analytics/shared/components/groups_dropdown_filter.vue'), - ProjectsDropdownFilter: () => - import('ee_component/analytics/shared/components/projects_dropdown_filter.vue'), - DateRangeDropdown: () => - import('ee_component/analytics/shared/components/date_range_dropdown.vue'), 'stage-nav-item': stageNavItem, }, data() { diff --git a/app/assets/javascripts/cycle_analytics/svg/icon_branch.svg b/app/assets/javascripts/cycle_analytics/svg/icon_branch.svg deleted file mode 100644 index 9f547d3d744..00000000000 --- a/app/assets/javascripts/cycle_analytics/svg/icon_branch.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path fill="#8C8C8C" fill-rule="evenodd" d="M9.678 6.722C9.353 5.167 8.053 4 6.5 4S3.647 5.167 3.322 6.722h-2.6c-.397 0-.722.35-.722.778 0 .428.325.778.722.778h2.6C3.647 9.833 4.947 11 6.5 11s2.853-1.167 3.178-2.722h2.6c.397 0 .722-.35.722-.778 0-.428-.325-.778-.722-.778h-2.6zM4.694 7.5c0-1.09.795-1.944 1.806-1.944 1.01 0 1.806.855 1.806 1.944 0 1.09-.795 1.944-1.806 1.944-1.01 0-1.806-.855-1.806-1.944z"/></svg> diff --git a/app/assets/javascripts/cycle_analytics/svg/icon_build_status.svg b/app/assets/javascripts/cycle_analytics/svg/icon_build_status.svg deleted file mode 100644 index b932d90618a..00000000000 --- a/app/assets/javascripts/cycle_analytics/svg/icon_build_status.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"><g fill="#31AF64" fill-rule="evenodd"><path d="M12.5 7c0-3.038-2.462-5.5-5.5-5.5S1.5 3.962 1.5 7s2.462 5.5 5.5 5.5 5.5-2.462 5.5-5.5zM0 7c0-3.866 3.134-7 7-7s7 3.134 7 7-3.134 7-7 7-7-3.134-7-7z"/><path d="M6.28 7.697L5.045 6.464c-.117-.117-.305-.117-.42-.002l-.614.614c-.11.113-.11.303.007.42l1.91 1.91c.19.19.51.197.703.004l.264-.265L9.997 6.04c.108-.107.107-.293-.01-.408l-.612-.614c-.114-.113-.298-.12-.41-.01L6.28 7.7z"/></g></svg> diff --git a/app/assets/javascripts/cycle_analytics/svg/icon_commit.svg b/app/assets/javascripts/cycle_analytics/svg/icon_commit.svg deleted file mode 100644 index 6a517756058..00000000000 --- a/app/assets/javascripts/cycle_analytics/svg/icon_commit.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><path fill="#8F8F8F" fill-rule="evenodd" d="M28.777 18c-.91-4.008-4.494-7-8.777-7-4.283 0-7.868 2.992-8.777 7H4.01C2.9 18 2 18.895 2 20c0 1.112.9 2 2.01 2h7.213c.91 4.008 4.494 7 8.777 7 4.283 0 7.868-2.992 8.777-7h7.214C37.1 22 38 21.105 38 20c0-1.112-.9-2-2.01-2h-7.213zM20 25c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5z"/></svg> diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue index 159f5ddd755..0d6657973c3 100644 --- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue @@ -69,15 +69,13 @@ export default { </p> </template> </gl-table> - <div class="gl-display-flex gl-justify-content-center"> - <gl-button - v-gl-modal.deploy-freeze-modal - data-testid="add-deploy-freeze" - category="primary" - variant="success" - > - {{ $options.translations.addDeployFreeze }} - </gl-button> - </div> + <gl-button + v-gl-modal.deploy-freeze-modal + data-testid="add-deploy-freeze" + category="primary" + variant="success" + > + {{ $options.translations.addDeployFreeze }} + </gl-button> </div> </template> diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue index 970197ef41b..273fa3f6be2 100644 --- a/app/assets/javascripts/design_management/components/delete_button.vue +++ b/app/assets/javascripts/design_management/components/delete_button.vue @@ -63,7 +63,7 @@ export default { title: s__('DesignManagement|Are you sure you want to archive the selected designs?'), actionPrimary: { text: s__('DesignManagement|Archive designs'), - attributes: { variant: 'warning' }, + attributes: { variant: 'warning', 'data-qa-selector': 'confirm_archiving_button' }, }, actionCancel: { text: __('Cancel'), diff --git a/app/assets/javascripts/design_management/components/design_note_pin.vue b/app/assets/javascripts/design_management/components/design_note_pin.vue index 2b5e62c2870..320e0654aab 100644 --- a/app/assets/javascripts/design_management/components/design_note_pin.vue +++ b/app/assets/javascripts/design_management/components/design_note_pin.vue @@ -17,19 +17,11 @@ export default { 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') @@ -41,13 +33,13 @@ export default { <template> <button - :style="pinStyle" + :style="position" :aria-label="pinLabel" :class="{ - 'btn-transparent comment-indicator': isNewNote, + 'btn-transparent comment-indicator gl-p-0': isNewNote, 'js-image-badge badge badge-pill': !isNewNote, }" - class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0" + class="gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-font-lg gl-outline-0!" type="button" @mousedown="$emit('mousedown', $event)" @mouseup="$emit('mouseup', $event)" diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue index 5c4a3ab5f94..88f3ce0b8ea 100644 --- a/app/assets/javascripts/design_management/components/design_overlay.vue +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -266,7 +266,7 @@ export default { type="button" role="button" :aria-label="$options.i18n.newCommentButtonLabel" - class="gl-absolute gl-w-full gl-h-full gl-p-0 gl-top-0 gl-left-0 gl-outline-0! btn-transparent design-detail-overlay-add-comment" + class="gl-absolute gl-w-full gl-h-full gl-p-0 gl-top-0 gl-left-0 gl-outline-0! btn-transparent gl-hover-cursor-crosshair" data-qa-selector="design_image_button" @mouseup="onAddCommentMouseup" ></button> @@ -276,7 +276,6 @@ export default { v-if="resolvedDiscussionsExpanded || !note.resolved" :key="note.id" :label="note.index" - :repositioning="isMovingNote(note.id)" :position=" isMovingNote(note.id) && movingNoteNewPosition ? getNotePositionStyle(movingNoteNewPosition) @@ -290,7 +289,6 @@ export default { <design-note-pin v-if="currentCommentForm" :position="currentCommentPositionStyle" - :repositioning="isMovingCurrentComment" @mousedown.stop="onNoteMousedown" @mouseup.stop="onNoteMouseup" /> diff --git a/app/assets/javascripts/design_management/components/design_presentation.vue b/app/assets/javascripts/design_management/components/design_presentation.vue index 84dbb2809d9..c4d904e0d91 100644 --- a/app/assets/javascripts/design_management/components/design_presentation.vue +++ b/app/assets/javascripts/design_management/components/design_presentation.vue @@ -286,7 +286,7 @@ export default { <template> <div ref="presentationViewport" - class="h-100 w-100 p-3 overflow-auto position-relative" + class="gl-h-full gl-w-full gl-p-5 overflow-auto gl-relative" :style="presentationStyle" @mousedown="onPresentationMousedown" @mousemove="onPresentationMousemove" @@ -297,7 +297,7 @@ export default { @touchend="onPresentationMouseup" @touchcancel="onPresentationMouseup" > - <div class="h-100 w-100 d-flex align-items-center position-relative"> + <div class="gl-h-full gl-w-full gl-display-flex gl-align-items-center gl-relative"> <design-image v-if="image" :image="image" diff --git a/app/assets/javascripts/design_management/components/design_scaler.vue b/app/assets/javascripts/design_management/components/design_scaler.vue index 55dee74bef5..8d26f84641e 100644 --- a/app/assets/javascripts/design_management/components/design_scaler.vue +++ b/app/assets/javascripts/design_management/components/design_scaler.vue @@ -51,7 +51,7 @@ export default { <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 class="gl-display-flex gl-justify-content-center gl-align-items-center gl-icon s16"> – </span> </button> diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue index df425e3b96d..fb8e74c8c4c 100644 --- a/app/assets/javascripts/design_management/components/design_sidebar.vue +++ b/app/assets/javascripts/design_management/components/design_sidebar.vue @@ -71,14 +71,6 @@ export default { resolvedCommentsToggleIcon() { return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right'; }, - showTodoButton() { - return this.glFeatures.designManagementTodoButton; - }, - sidebarWrapperClass() { - return { - 'gl-pt-0': this.showTodoButton, - }; - }, }, watch: { isResolvedCommentsPopoverHidden(newVal) { @@ -121,12 +113,11 @@ export default { </script> <template> - <div class="image-notes" :class="sidebarWrapperClass" @click="handleSidebarClick"> + <div class="image-notes gl-pt-0" @click="handleSidebarClick"> <div - v-if="showTodoButton" class="gl-py-4 gl-mb-4 gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" > - <span>{{ __('To-Do') }}</span> + <span>{{ __('To Do') }}</span> <design-todo-button :design="design" @error="$emit('todoError', $event)" /> </div> <h2 class="gl-font-weight-bold gl-mt-0"> diff --git a/app/assets/javascripts/design_management/components/image.vue b/app/assets/javascripts/design_management/components/image.vue index 91b7b576e0c..53062e6ebb0 100644 --- a/app/assets/javascripts/design_management/components/image.vue +++ b/app/assets/javascripts/design_management/components/image.vue @@ -93,8 +93,8 @@ export default { </script> <template> - <div class="m-auto js-design-image"> - <gl-icon v-if="imageError" class="text-secondary-100" name="media-broken" :size="48" /> + <div class="gl-mx-auto gl-my-auto js-design-image"> + <gl-icon v-if="imageError" class="gl-text-gray-200" name="media-broken" :size="48" /> <img v-show="!imageError" ref="contentImg" diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue index 36ea812d92e..fa09c7c15cc 100644 --- a/app/assets/javascripts/design_management/components/list/item.vue +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -132,7 +132,13 @@ export default { > <div v-if="icon.name" data-testid="designEvent" class="design-event gl-absolute"> <span :title="icon.tooltip" :aria-label="icon.tooltip"> - <gl-icon :name="icon.name" :size="18" :class="icon.classes" /> + <gl-icon + :name="icon.name" + :size="18" + :class="icon.classes" + data-qa-selector="design_status_icon" + :data-qa-status="icon.name" + /> </span> </div> <gl-intersection-observer @appear="onAppear"> @@ -149,6 +155,7 @@ export default { :alt="filename" class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img" data-qa-selector="design_image" + :data-qa-filename="filename" @load="onImageLoad" @error="onImageError" /> diff --git a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue index afca8ed2c6f..2719d701c12 100644 --- a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue +++ b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue @@ -64,9 +64,9 @@ export default { </script> <template> - <div v-if="designsCount" class="d-flex align-items-center"> + <div v-if="designsCount" class="gl-display-flex gl-align-items-center"> {{ paginationText }} - <gl-button-group class="ml-3 mr-3"> + <gl-button-group class="gl-mx-5"> <gl-button :disabled="!previousDesign" :title="s__('DesignManagement|Go to previous design')" diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue index a1cb57123ab..8d25d467d59 100644 --- a/app/assets/javascripts/design_management/components/toolbar/index.vue +++ b/app/assets/javascripts/design_management/components/toolbar/index.vue @@ -106,12 +106,12 @@ export default { > <gl-icon name="close" /> </router-link> - <div class="overflow-hidden d-flex align-items-center"> - <h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2> - <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small> + <div class="gl-overflow-hidden gl-display-flex gl-align-items-center"> + <h2 class="gl-m-0 str-truncated-100 gl-font-base">{{ filename }}</h2> + <small v-if="updatedAt" class="gl-text-gray-500">{{ updatedText }}</small> </div> </div> - <design-navigation :id="id" class="ml-auto flex-shrink-0" /> + <design-navigation :id="id" class="gl-ml-auto gl-flex-shrink-0" /> <gl-button :href="image" icon="download" /> <delete-button v-if="isLatestVersion && canDeleteDesign" diff --git a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue index 7254b7cd16a..6694b0dab8d 100644 --- a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue +++ b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue @@ -83,7 +83,7 @@ export default { <template> <div - class="w-100 position-relative" + class="gl-w-full gl-relative" @dragstart.prevent.stop @dragend.prevent.stop @dragover.prevent.stop @@ -93,7 +93,7 @@ export default { > <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" + class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" @click="openFileUpload" > <div @@ -127,9 +127,9 @@ export default { <transition name="design-dropzone-fade"> <div v-show="dragging && !isDraggingDesign" - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" > - <div v-show="!isDragDataValid" class="mw-50 text-center"> + <div v-show="!isDragDataValid" class="mw-50 gl-text-center"> <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Oh no!') }}</h3> <span>{{ __( @@ -137,7 +137,7 @@ export default { ) }}</span> </div> - <div v-show="isDragDataValid" class="mw-50 text-center"> + <div v-show="isDragDataValid" class="mw-50 gl-text-center"> <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Incoming!') }}</h3> <span>{{ __('Drop your designs to start your upload.') }}</span> </div> diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql index 96efa8e8242..efa61edf51a 100644 --- a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql @@ -6,6 +6,7 @@ query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) { id issue(iid: $iid) { designCollection { + copyState designs(atVersion: $atVersion) { nodes { ...DesignListItem diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js index 0c2858bb14b..62bcf216add 100644 --- a/app/assets/javascripts/design_management/mixins/all_designs.js +++ b/app/assets/javascripts/design_management/mixins/all_designs.js @@ -8,7 +8,7 @@ import { DESIGNS_ROUTE_NAME } from '../router/constants'; export default { mixins: [allVersionsMixin], apollo: { - designs: { + designCollection: { query: getDesignListQuery, variables() { return { @@ -25,10 +25,11 @@ export default { 'designs', 'nodes', ]); - if (designNodes) { - return designNodes; - } - return []; + const copyState = propertyOf(data)(['project', 'issue', 'designCollection', 'copyState']); + return { + designs: designNodes, + copyState, + }; }, error() { this.error = true; @@ -42,13 +43,26 @@ export default { ); this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } }); } + if (this.designCollection.copyState === 'ERROR') { + createFlash( + s__( + 'DesignManagement|There was an error moving your designs. Please upload your designs below.', + ), + 'warning', + ); + } }, }, }, data() { return { - designs: [], + designCollection: null, error: false, }; }, + computed: { + designs() { + return this.designCollection?.designs || []; + }, + }, }; diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index c6225c516e2..6a96b06dcd8 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -40,6 +40,8 @@ import { trackDesignDetailView } from '../../utils/tracking'; import { DESIGNS_ROUTE_NAME } from '../../router/constants'; import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; +const DEFAULT_SCALE = 1; + export default { components: { ApolloMutation, @@ -65,7 +67,7 @@ export default { comment: '', annotationCoordinates: null, errorMessage: '', - scale: 1, + scale: DEFAULT_SCALE, resolvedDiscussionsExpanded: false, }; }, @@ -157,6 +159,11 @@ export default { beforeDestroy() { Mousetrap.unbind('esc', this.closeDesign); }, + beforeRouteUpdate(to, from, next) { + // reset scale when the active design changes + this.scale = DEFAULT_SCALE; + next(); + }, methods: { addImageDiffNoteToStore( store, @@ -300,11 +307,13 @@ export default { <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" + class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" > - <gl-loading-icon v-if="isFirstLoading" size="xl" class="align-self-center" /> + <gl-loading-icon v-if="isFirstLoading" size="xl" class="gl-align-self-center" /> <template v-else> - <div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"> + <div + class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative" + > <design-destroyer :filenames="[design.filename]" :project-path="projectPath" @@ -323,7 +332,7 @@ export default { </template> </design-destroyer> - <div v-if="errorMessage" class="p-3"> + <div v-if="errorMessage" class="gl-p-5"> <gl-alert variant="danger" @dismiss="errorMessage = null"> {{ errorMessage }} </gl-alert> @@ -340,7 +349,9 @@ export default { @moveNote="onMoveNote" /> - <div class="design-scaler-wrapper position-absolute mb-4 d-flex-center"> + <div + class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center" + > <design-scaler @scale="scale = $event" /> </div> </div> diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 6c4c8c75054..6e71dca41e9 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -3,6 +3,7 @@ import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui'; import VueDraggable from 'vuedraggable'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__, sprintf } from '~/locale'; +import { getFilename } from '~/lib/utils/file_upload'; import UploadButton from '../components/upload/button.vue'; import DeleteButton from '../components/delete_button.vue'; import Design from '../components/list/item.vue'; @@ -31,7 +32,7 @@ import { isValidDesignFile, moveDesignOptimisticResponse, } from '../utils/design_management_utils'; -import { getFilename } from '~/lib/utils/file_upload'; +import { trackDesignCreate, trackDesignUpdate } from '../utils/tracking'; import { DESIGNS_ROUTE_NAME } from '../router/constants'; const MAXIMUM_FILE_UPLOAD_LIMIT = 10; @@ -71,11 +72,14 @@ export default { selectedDesigns: [], isDraggingDesign: false, reorderedDesigns: null, + isReorderingInProgress: false, }; }, computed: { isLoading() { - return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading; + return ( + this.$apollo.queries.designCollection.loading || this.$apollo.queries.permissions.loading + ); }, isSaving() { return this.filesToBeSaved.length > 0; @@ -109,6 +113,9 @@ export default { isDesignListEmpty() { return !this.isSaving && !this.hasDesigns; }, + isDesignCollectionCopying() { + return this.designCollection && this.designCollection.copyState === 'IN_PROGRESS'; + }, designDropzoneWrapperClass() { return this.isDesignListEmpty ? 'col-12' @@ -180,6 +187,7 @@ export default { updateStoreAfterUploadDesign(store, designManagementUpload, this.projectQueryBody); }, onUploadDesignDone(res) { + // display any warnings, if necessary const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || []; const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles); if (skippedWarningMessage) { @@ -190,7 +198,19 @@ export default { if (!this.isLatestVersion) { this.$router.push({ name: DESIGNS_ROUTE_NAME }); } + + // reset state this.resetFilesToBeSaved(); + this.trackUploadDesign(res); + }, + trackUploadDesign(res) { + (res?.data?.designManagementUpload?.designs || []).forEach(design => { + if (design.event === 'CREATION') { + trackDesignCreate(); + } else if (design.event === 'MODIFICATION') { + trackDesignUpdate(); + } + }); }, onUploadDesignError() { this.resetFilesToBeSaved(); @@ -277,6 +297,7 @@ export default { return variables; }, reorderDesigns({ moved: { newIndex, element } }) { + this.isReorderingInProgress = true; this.$apollo .mutate({ mutation: moveDesignMutation, @@ -287,6 +308,9 @@ export default { }) .catch(() => { createFlash(MOVE_DESIGN_ERROR); + }) + .finally(() => { + this.isReorderingInProgress = false; }); }, onDesignMove(designs) { @@ -311,7 +335,7 @@ export default { @mouseenter="toggleOnPasteListener" @mouseleave="toggleOffPasteListener" > - <header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex"> + <header v-if="showToolbar" class="row-content-block gl-border-t-0 gl-p-3 gl-display-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> @@ -339,6 +363,7 @@ export default { button-category="secondary" button-class="gl-mr-3" button-size="small" + data-qa-selector="archive_button" :loading="loading" :has-selected-designs="hasSelectedDesigns" @deleteSelectedDesigns="mutate()" @@ -350,15 +375,30 @@ export default { </div> </div> </header> - <div class="mt-4"> + <div class="gl-mt-6"> <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> + <header + v-else-if="isDesignCollectionCopying" + class="card" + data-testid="design-collection-is-copying" + > + <div class="card-header design-card-header gl-border-b-0"> + <div class="card-title gl-display-flex gl-align-items-center gl-my-0 gl-h-7"> + {{ + s__( + 'DesignManagement|Your designs are being copied and are on their way… Please refresh to update.', + ) + }} + </div> + </div> + </header> <vue-draggable v-else :value="designs" - :disabled="!isLatestVersion" + :disabled="!isLatestVersion || isReorderingInProgress" v-bind="$options.dragOptions" tag="ol" draggable=".js-design-tile" @@ -390,6 +430,8 @@ export default { :checked="isDesignSelected(design.filename)" type="checkbox" class="design-checkbox" + data-qa-selector="design_checkbox" + :data-qa-design="design.filename" @change="changeSelectedDesigns(design.filename)" /> </li> @@ -399,6 +441,7 @@ export default { :is-dragging-design="isDraggingDesign" :class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }" :has-designs="hasDesigns" + data-qa-selector="design_dropzone_content" @change="onUploadDesign" /> </li> diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js index ff41136fd54..fc0530ff977 100644 --- a/app/assets/javascripts/design_management/utils/cache_update.js +++ b/app/assets/javascripts/design_management/utils/cache_update.js @@ -1,6 +1,6 @@ /* eslint-disable @gitlab/require-i18n-strings */ -import { groupBy } from 'lodash'; +import { differenceBy } from 'lodash'; import produce from 'immer'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils'; @@ -132,10 +132,13 @@ const addNewDesignToStore = (store, designManagementUpload, query) => { const data = produce(sourceData, draftData => { const currentDesigns = extractDesigns(draftData); - const existingDesigns = groupBy(currentDesigns, 'filename'); - const newDesigns = currentDesigns.concat( - designManagementUpload.designs.filter(d => !existingDesigns[d.filename]), - ); + const difference = differenceBy(designManagementUpload.designs, currentDesigns, 'filename'); + + const newDesigns = currentDesigns + .map(design => { + return designManagementUpload.designs.find(d => d.filename === design.filename) || design; + }) + .concat(difference); let newVersionNode; const findNewVersions = designManagementUpload.designs.find(design => design.versions); @@ -155,6 +158,7 @@ const addNewDesignToStore = (store, designManagementUpload, query) => { const updatedDesigns = { __typename: 'DesignCollection', + copyState: 'READY', designs: { __typename: 'DesignConnection', nodes: newDesigns, diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js index 93e4d6060c3..687e793d3df 100644 --- a/app/assets/javascripts/design_management/utils/design_management_utils.js +++ b/app/assets/javascripts/design_management/utils/design_management_utils.js @@ -65,6 +65,10 @@ export const designUploadOptimisticResponse = files => { fullPath: '', notesCount: 0, event: 'NONE', + currentUserTodos: { + __typename: 'TodoConnection', + nodes: [], + }, diffRefs: { __typename: 'DiffRefs', baseSha: '', diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js index 49fa306914c..4a39268c38b 100644 --- a/app/assets/javascripts/design_management/utils/tracking.js +++ b/app/assets/javascripts/design_management/utils/tracking.js @@ -1,9 +1,16 @@ 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'; +const DESIGN_TRACKING_CONTEXT_SCHEMAS = { + VIEW_DESIGN_SCHEMA: 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0', +}; +const DESIGN_TRACKING_EVENTS = { + VIEW_DESIGN: 'view_design', + CREATE_DESIGN: 'create_design', + UPDATE_DESIGN: 'update_design', +}; + +export const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design'; export function trackDesignDetailView( referer = '', @@ -11,10 +18,11 @@ export function trackDesignDetailView( designVersion = 1, latestVersion = false, ) { - Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, { - label: DESIGN_TRACKING_EVENT_NAME, + const eventName = DESIGN_TRACKING_EVENTS.VIEW_DESIGN; + Tracking.event(DESIGN_TRACKING_PAGE_NAME, eventName, { + label: eventName, context: { - schema: DESIGN_TRACKING_CONTEXT_SCHEMA, + schema: DESIGN_TRACKING_CONTEXT_SCHEMAS.VIEW_DESIGN_SCHEMA, data: { 'design-version-number': designVersion, 'design-is-current-version': latestVersion, @@ -24,3 +32,11 @@ export function trackDesignDetailView( }, }); } + +export function trackDesignCreate() { + return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENTS.CREATE_DESIGN); +} + +export function trackDesignUpdate() { + return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENTS.UPDATE_DESIGN); +} diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js deleted file mode 100644 index dd60e2c7684..00000000000 --- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js +++ /dev/null @@ -1,65 +0,0 @@ -/* global CommentsStore */ - -import $ from 'jquery'; -import Vue from 'vue'; -import { __ } from '~/locale'; - -const CommentAndResolveBtn = Vue.extend({ - props: { - discussionId: { - type: String, - required: true, - }, - }, - data() { - return { - textareaIsEmpty: true, - discussion: {}, - }; - }, - computed: { - showButton() { - if (this.discussion) { - return this.discussion.isResolvable(); - } - return false; - }, - isDiscussionResolved() { - return this.discussion.isResolved(); - }, - buttonText() { - if (this.textareaIsEmpty) { - return this.isDiscussionResolved ? __('Unresolve thread') : __('Resolve thread'); - } - return this.isDiscussionResolved - ? __('Comment & unresolve thread') - : __('Comment & resolve thread'); - }, - }, - created() { - if (this.discussionId) { - this.discussion = CommentsStore.state[this.discussionId]; - } - }, - mounted() { - if (!this.discussionId) return; - - const $textarea = $( - `.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`, - ); - this.textareaIsEmpty = $textarea.val() === ''; - - $textarea.on('input.comment-and-resolve-btn', () => { - this.textareaIsEmpty = $textarea.val() === ''; - }); - }, - destroyed() { - if (!this.discussionId) return; - - $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`).off( - 'input.comment-and-resolve-btn', - ); - }, -}); - -Vue.component('comment-and-resolve-btn', CommentAndResolveBtn); diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js deleted file mode 100644 index b5a781cbc92..00000000000 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ /dev/null @@ -1,189 +0,0 @@ -/* global CommentsStore */ - -import $ from 'jquery'; -import Vue from 'vue'; -import collapseIcon from '../icons/collapse_icon.svg'; -import Notes from '../../notes'; -import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; -import { n__ } from '~/locale'; - -const DiffNoteAvatars = Vue.extend({ - components: { - userAvatarImage, - }, - props: { - discussionId: { - type: String, - required: true, - }, - }, - data() { - return { - isVisible: false, - lineType: '', - storeState: CommentsStore.state, - shownAvatars: 3, - collapseIcon, - }; - }, - computed: { - discussionClassName() { - return `js-diff-avatars-${this.discussionId}`; - }, - notesSubset() { - let notes = []; - - if (this.discussion) { - notes = Object.keys(this.discussion.notes) - .slice(0, this.shownAvatars) - .map(noteId => this.discussion.notes[noteId]); - } - - return notes; - }, - extraNotesTitle() { - if (this.discussion) { - const extra = this.discussion.notesCount() - this.shownAvatars; - - return n__('%d more comment', '%d more comments', extra); - } - - return ''; - }, - discussion() { - return this.storeState[this.discussionId]; - }, - notesCount() { - if (this.discussion) { - return this.discussion.notesCount(); - } - - return 0; - }, - moreText() { - const plusSign = this.notesCount < 100 ? '+' : ''; - - return `${plusSign}${this.notesCount - this.shownAvatars}`; - }, - }, - watch: { - storeState: { - handler() { - this.$nextTick(() => { - $('.has-tooltip', this.$el).tooltip('_fixTitle'); - - // We need to add/remove a class to an element that is outside the Vue instance - this.addNoCommentClass(); - }); - }, - deep: true, - }, - }, - mounted() { - this.$nextTick(() => { - this.addNoCommentClass(); - this.setDiscussionVisible(); - - this.lineType = $(this.$el) - .closest('.diff-line-num') - .hasClass('old_line') - ? 'old' - : 'new'; - }); - - $(document).on('toggle.comments', () => { - this.$nextTick(() => { - this.setDiscussionVisible(); - }); - }); - }, - beforeDestroy() { - this.addNoCommentClass(); - $(document).off('toggle.comments'); - }, - methods: { - clickedAvatar(e) { - Notes.instance.onAddDiffNote(e); - - // Toggle the active state of the toggle all button - this.toggleDiscussionsToggleState(); - - this.$nextTick(() => { - this.setDiscussionVisible(); - - $('.has-tooltip', this.$el).tooltip('_fixTitle'); - $('.has-tooltip', this.$el).tooltip('hide'); - }); - }, - addNoCommentClass() { - const { notesCount } = this; - - $(this.$el) - .closest('.js-avatar-container') - .toggleClass('no-comment-btn', notesCount > 0) - .nextUntil('.js-avatar-container') - .toggleClass('no-comment-btn', notesCount > 0); - }, - toggleDiscussionsToggleState() { - const $notesHolders = $(this.$el) - .closest('.code') - .find('.notes_holder'); - const $visibleNotesHolders = $notesHolders.filter(':visible'); - const $toggleDiffCommentsBtn = $(this.$el) - .closest('.diff-file') - .find('.js-toggle-diff-comments'); - - $toggleDiffCommentsBtn.toggleClass( - 'active', - $notesHolders.length === $visibleNotesHolders.length, - ); - }, - setDiscussionVisible() { - this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is( - ':visible', - ); - }, - getTooltipText(note) { - return `${note.authorName}: ${note.noteTruncated}`; - }, - }, - template: ` - <div class="diff-comment-avatar-holders" - :class="discussionClassName" - v-show="notesCount !== 0"> - <div v-if="!isVisible"> - <!-- FIXME: Pass an alt attribute here for accessibility --> - <user-avatar-image - v-for="note in notesSubset" - :key="note.id" - class="diff-comment-avatar js-diff-comment-avatar" - @click.native="clickedAvatar($event)" - :img-src="note.authorAvatar" - :tooltip-text="getTooltipText(note)" - :data-line-type="lineType" - :size="19" - data-html="true" - /> - <span v-if="notesCount > shownAvatars" - class="diff-comments-more-count has-tooltip js-diff-comment-avatar" - data-container="body" - data-placement="top" - ref="extraComments" - role="button" - :data-line-type="lineType" - :title="extraNotesTitle" - @click="clickedAvatar($event)">{{ moreText }}</span> - </div> - <button class="diff-notes-collapse js-diff-comment-avatar" - type="button" - aria-label="Show comments" - :data-line-type="lineType" - @click="clickedAvatar($event)" - v-if="isVisible" - v-html="collapseIcon"> - </button> - </div> - `, -}); - -Vue.component('diff-note-avatars', DiffNoteAvatars); diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js deleted file mode 100644 index 1de00c9f08b..00000000000 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js +++ /dev/null @@ -1,210 +0,0 @@ -/* eslint-disable func-names, no-continue */ -/* global CommentsStore */ - -import $ from 'jquery'; -import 'vendor/jquery.scrollTo'; -import Vue from 'vue'; -import { __ } from '~/locale'; - -import DiscussionMixins from '../mixins/discussion'; - -const JumpToDiscussion = Vue.extend({ - mixins: [DiscussionMixins], - props: { - discussionId: { - type: String, - required: true, - }, - }, - data() { - return { - discussions: CommentsStore.state, - discussion: {}, - }; - }, - computed: { - buttonText() { - if (this.discussionId) { - return __('Jump to next unresolved thread'); - } - return __('Jump to first unresolved thread'); - }, - allResolved() { - return this.unresolvedDiscussionCount === 0; - }, - showButton() { - if (this.discussionId) { - if (this.unresolvedDiscussionCount > 1) { - return true; - } - return this.discussionId !== this.lastResolvedId; - } - return this.unresolvedDiscussionCount >= 1; - }, - lastResolvedId() { - let lastId; - Object.keys(this.discussions).forEach(discussionId => { - const discussion = this.discussions[discussionId]; - - if (!discussion.isResolved()) { - lastId = discussion.id; - } - }); - return lastId; - }, - }, - created() { - this.discussion = this.discussions[this.discussionId]; - }, - methods: { - jumpToNextUnresolvedDiscussion() { - let discussionsSelector; - let discussionIdsInScope; - let firstUnresolvedDiscussionId; - let nextUnresolvedDiscussionId; - let activeTab = window.mrTabs.currentAction; - let hasDiscussionsToJumpTo = true; - let jumpToFirstDiscussion = !this.discussionId; - - const discussionIdsForElements = function(elements) { - return elements - .map(function() { - return $(this).attr('data-discussion-id'); - }) - .toArray(); - }; - - const { discussions } = this; - - if (activeTab === 'diffs') { - discussionsSelector = '.diffs .notes[data-discussion-id]'; - discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); - - let unresolvedDiscussionCount = 0; - - for (let i = 0; i < discussionIdsInScope.length; i += 1) { - const discussionId = discussionIdsInScope[i]; - const discussion = discussions[discussionId]; - if (discussion && !discussion.isResolved()) { - unresolvedDiscussionCount += 1; - } - } - - if (this.discussionId && !this.discussion.isResolved()) { - // If this is the last unresolved discussion on the diffs tab, - // there are no discussions to jump to. - if (unresolvedDiscussionCount === 1) { - hasDiscussionsToJumpTo = false; - } - } else if (unresolvedDiscussionCount === 0) { - // If there are no unresolved discussions on the diffs tab at all, - // there are no discussions to jump to. - hasDiscussionsToJumpTo = false; - } - } else if (activeTab !== 'show') { - // If we are on the commits or builds tabs, - // there are no discussions to jump to. - hasDiscussionsToJumpTo = false; - } - - if (!hasDiscussionsToJumpTo) { - // If there are no discussions to jump to on the current page, - // switch to the notes tab and jump to the first discussion there. - window.mrTabs.activateTab('show'); - activeTab = 'show'; - jumpToFirstDiscussion = true; - } - - if (activeTab === 'show') { - discussionsSelector = '.discussion[data-discussion-id]'; - discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); - } - - let currentDiscussionFound = false; - for (let i = 0; i < discussionIdsInScope.length; i += 1) { - const discussionId = discussionIdsInScope[i]; - const discussion = discussions[discussionId]; - - if (!discussion) { - // Discussions for comments on commits in this MR don't have a resolved status. - continue; - } - - if (!firstUnresolvedDiscussionId && !discussion.isResolved()) { - firstUnresolvedDiscussionId = discussionId; - - if (jumpToFirstDiscussion) { - break; - } - } - - if (!jumpToFirstDiscussion) { - if (currentDiscussionFound) { - if (!discussion.isResolved()) { - nextUnresolvedDiscussionId = discussionId; - break; - } else { - continue; - } - } - - if (discussionId === this.discussionId) { - currentDiscussionFound = true; - } - } - } - - nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId; - - if (!nextUnresolvedDiscussionId) { - return; - } - - let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`); - - if (activeTab === 'show') { - $target = $target.closest('.note-discussion'); - - // If the next discussion is closed, toggle it open. - if ($target.find('.js-toggle-content').is(':hidden')) { - $target.find('.js-toggle-button i').trigger('click'); - } - } else if (activeTab === 'diffs') { - // Resolved discussions are hidden in the diffs tab by default. - // If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab. - // When jumping between unresolved discussions on the diffs tab, we show them. - $target.closest('.content').show(); - - const $notesHolder = $target.closest('tr.notes_holder'); - - // Image diff discussions does not use notes_holder - // so we should keep original $target value in those cases - if ($notesHolder.length > 0) { - $target = $notesHolder; - } - - $target.show(); - - // If we are on the diffs tab, we don't scroll to the discussion itself, but to - // 4 diff lines above it: the line the discussion was in response to + 3 context - let prevEl; - for (let i = 0; i < 4; i += 1) { - prevEl = $target.prev(); - - // If the discussion doesn't have 4 lines above it, we'll have to do with fewer. - if (!prevEl.hasClass('line_holder')) { - break; - } - - $target = prevEl; - } - } - - $.scrollTo($target, { - offset: -150, - }); - }, - }, -}); - -Vue.component('jump-to-discussion', JumpToDiscussion); diff --git a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js b/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js deleted file mode 100644 index e0c09aa0eee..00000000000 --- a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js +++ /dev/null @@ -1,28 +0,0 @@ -/* global CommentsStore */ - -import Vue from 'vue'; - -const NewIssueForDiscussion = Vue.extend({ - props: { - discussionId: { - type: String, - required: true, - }, - }, - data() { - return { - discussions: CommentsStore.state, - }; - }, - computed: { - discussion() { - return this.discussions[this.discussionId]; - }, - showButton() { - if (this.discussion) return !this.discussion.isResolved(); - return false; - }, - }, -}); - -Vue.component('new-issue-for-discussion-btn', NewIssueForDiscussion); diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js deleted file mode 100644 index 0943712d0c5..00000000000 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ /dev/null @@ -1,145 +0,0 @@ -/* global CommentsStore */ -/* global ResolveService */ - -import $ from 'jquery'; -import Vue from 'vue'; -import { deprecatedCreateFlash as Flash } from '../../flash'; -import { sprintf, __ } from '~/locale'; - -const ResolveBtn = Vue.extend({ - props: { - noteId: { - type: Number, - required: true, - }, - discussionId: { - type: String, - required: true, - }, - resolved: { - type: Boolean, - required: true, - }, - canResolve: { - type: Boolean, - required: true, - }, - resolvedBy: { - type: String, - required: true, - }, - authorName: { - type: String, - required: true, - }, - authorAvatar: { - type: String, - required: true, - }, - noteTruncated: { - type: String, - required: true, - }, - }, - data() { - return { - discussions: CommentsStore.state, - loading: false, - }; - }, - computed: { - discussion() { - return this.discussions[this.discussionId]; - }, - note() { - return this.discussion ? this.discussion.getNote(this.noteId) : {}; - }, - buttonText() { - if (this.isResolved) { - return sprintf(__('Resolved by %{resolvedByName}'), { - resolvedByName: this.resolvedByName, - }); - } else if (this.canResolve) { - return __('Mark as resolved'); - } - - return __('Unable to resolve'); - }, - isResolved() { - if (this.note) { - return this.note.resolved; - } - - return false; - }, - resolvedByName() { - return this.note.resolved_by; - }, - }, - watch: { - discussions: { - handler: 'updateTooltip', - deep: true, - }, - }, - mounted() { - $(this.$refs.button).tooltip({ - container: 'body', - }); - }, - beforeDestroy() { - CommentsStore.delete(this.discussionId, this.noteId); - }, - created() { - CommentsStore.create({ - discussionId: this.discussionId, - noteId: this.noteId, - canResolve: this.canResolve, - resolved: this.resolved, - resolvedBy: this.resolvedBy, - authorName: this.authorName, - authorAvatar: this.authorAvatar, - noteTruncated: this.noteTruncated, - }); - }, - methods: { - updateTooltip() { - this.$nextTick(() => { - $(this.$refs.button) - .tooltip('hide') - .tooltip('_fixTitle'); - }); - }, - resolve() { - if (!this.canResolve) return; - - let promise; - this.loading = true; - - if (this.isResolved) { - promise = ResolveService.unresolve(this.noteId); - } else { - promise = ResolveService.resolve(this.noteId); - } - - promise - .then(resp => resp.json()) - .then(data => { - this.loading = false; - - const resolvedBy = data ? data.resolved_by : null; - - CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolvedBy); - this.discussion.updateHeadline(data); - gl.mrWidget.checkStatus(); - this.updateTooltip(); - }) - .catch( - () => - new Flash(__('An error occurred when trying to resolve a comment. Please try again.')), - ); - }, - }, -}); - -Vue.component('resolve-btn', ResolveBtn); diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js b/app/assets/javascripts/diff_notes/components/resolve_count.js deleted file mode 100644 index f960853b25b..00000000000 --- a/app/assets/javascripts/diff_notes/components/resolve_count.js +++ /dev/null @@ -1,28 +0,0 @@ -/* global CommentsStore */ - -import Vue from 'vue'; - -import DiscussionMixins from '../mixins/discussion'; - -window.ResolveCount = Vue.extend({ - mixins: [DiscussionMixins], - props: { - loggedOut: { - type: Boolean, - required: true, - }, - }, - data() { - return { - discussions: CommentsStore.state, - }; - }, - computed: { - allResolved() { - return this.resolvedDiscussionCount === this.discussionCount; - }, - resolvedCountText() { - return this.discussionCount === 1 ? 'discussion' : 'discussions'; - }, - }, -}); diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js deleted file mode 100644 index 92862d4c933..00000000000 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ /dev/null @@ -1,72 +0,0 @@ -/* eslint-disable func-names, new-cap */ - -import $ from 'jquery'; -import Vue from 'vue'; -import './models/discussion'; -import './models/note'; -import './stores/comments'; -import './services/resolve'; -import './mixins/discussion'; -import './components/comment_resolve_btn'; -import './components/jump_to_discussion'; -import './components/resolve_btn'; -import './components/resolve_count'; -import './components/diff_note_avatars'; -import './components/new_issue_for_discussion'; - -export default () => { - const projectPathHolder = - document.querySelector('.merge-request') || document.querySelector('.commit-box'); - const { projectPath } = projectPathHolder.dataset; - const COMPONENT_SELECTOR = - 'resolve-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn'; - - window.gl = window.gl || {}; - window.gl.diffNoteApps = {}; - - window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath); - - gl.diffNotesCompileComponents = () => { - $('diff-note-avatars').each(function() { - const tmp = Vue.extend({ - template: $(this).get(0).outerHTML, - }); - const tmpApp = new tmp().$mount(); - - $(this).replaceWith(tmpApp.$el); - $(tmpApp.$el).one('remove.vue', () => { - tmpApp.$destroy(); - tmpApp.$el.remove(); - }); - }); - - const $components = $(COMPONENT_SELECTOR).filter(function() { - return $(this).closest('resolve-count').length !== 1; - }); - - if ($components) { - $components.each(function() { - const $this = $(this); - const noteId = $this.attr(':note-id'); - const discussionId = $this.attr(':discussion-id'); - - if ($this.is('comment-and-resolve-btn') && !discussionId) return; - - const tmp = Vue.extend({ - template: $this.get(0).outerHTML, - }); - const tmpApp = new tmp().$mount(); - - if (noteId) { - gl.diffNoteApps[`note_${noteId}`] = tmpApp; - } - - $this.replaceWith(tmpApp.$el); - }); - } - }; - - gl.diffNotesCompileComponents(); - - $(window).trigger('resize.nav'); -}; diff --git a/app/assets/javascripts/diff_notes/icons/collapse_icon.svg b/app/assets/javascripts/diff_notes/icons/collapse_icon.svg deleted file mode 100644 index bd4b393cfaa..00000000000 --- a/app/assets/javascripts/diff_notes/icons/collapse_icon.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="11" height="11" viewBox="0 0 9 13"><path d="M2.57568253,6.49866948 C2.50548852,6.57199715 2.44637866,6.59708255 2.39835118,6.57392645 C2.3503237,6.55077034 2.32631032,6.48902165 2.32631032,6.38867852 L2.32631032,-2.13272614 C2.32631032,-2.23306927 2.3503237,-2.29481796 2.39835118,-2.31797406 C2.44637866,-2.34113017 2.50548852,-2.31604477 2.57568253,-2.24271709 L6.51022184,1.86747129 C6.53977721,1.8983461 6.56379059,1.93500939 6.5822627,1.97746225 L6.5822627,2.27849013 C6.56379059,2.31708364 6.53977721,2.35374693 6.51022184,2.38848109 L2.57568253,6.49866948 Z" transform="translate(4.454287, 2.127976) rotate(90.000000) translate(-4.454287, -2.127976) "></path><path d="M3.74312342,2.09553332 C3.74312342,1.99519019 3.77821989,1.9083561 3.8484139,1.83502843 C3.91860791,1.76170075 4.00173115,1.72503747 4.09778611,1.72503747 L4.80711151,1.72503747 C4.90316647,1.72503747 4.98628971,1.76170075 5.05648372,1.83502843 C5.12667773,1.9083561 5.16177421,1.99519019 5.16177421,2.09553332 L5.16177421,10.2464421 C5.16177421,10.3467853 5.12667773,10.4336194 5.05648372,10.506947 C4.98628971,10.5802747 4.90316647,10.616938 4.80711151,10.616938 L4.09778611,10.616938 C4.00173115,10.616938 3.91860791,10.5802747 3.8484139,10.506947 C3.77821989,10.4336194 3.74312342,10.3467853 3.74312342,10.2464421 L3.74312342,2.09553332 Z" transform="translate(4.452449, 6.170988) rotate(-90.000000) translate(-4.452449, -6.170988) "></path><path d="M2.57568253,14.6236695 C2.50548852,14.6969971 2.44637866,14.7220826 2.39835118,14.6989264 C2.3503237,14.6757703 2.32631032,14.6140216 2.32631032,14.5136785 L2.32631032,5.99227386 C2.32631032,5.89193073 2.3503237,5.83018204 2.39835118,5.80702594 C2.44637866,5.78386983 2.50548852,5.80895523 2.57568253,5.88228291 L6.51022184,9.99247129 C6.53977721,10.0233461 6.56379059,10.0600094 6.5822627,10.1024622 L6.5822627,10.4034901 C6.56379059,10.4420836 6.53977721,10.4787469 6.51022184,10.5134811 L2.57568253,14.6236695 Z" transform="translate(4.454287, 10.252976) scale(1, -1) rotate(90.000000) translate(-4.454287, -10.252976) "></path></svg> diff --git a/app/assets/javascripts/diff_notes/mixins/discussion.js b/app/assets/javascripts/diff_notes/mixins/discussion.js deleted file mode 100644 index ef3001393cf..00000000000 --- a/app/assets/javascripts/diff_notes/mixins/discussion.js +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable guard-for-in, no-restricted-syntax, */ - -const DiscussionMixins = { - computed: { - discussionCount() { - return Object.keys(this.discussions).length; - }, - resolvedDiscussionCount() { - let resolvedCount = 0; - - for (const discussionId in this.discussions) { - const discussion = this.discussions[discussionId]; - - if (discussion.isResolved()) { - resolvedCount += 1; - } - } - - return resolvedCount; - }, - unresolvedDiscussionCount() { - let unresolvedCount = 0; - - for (const discussionId in this.discussions) { - const discussion = this.discussions[discussionId]; - - if (!discussion.isResolved()) { - unresolvedCount += 1; - } - } - - return unresolvedCount; - }, - }, -}; - -export default DiscussionMixins; diff --git a/app/assets/javascripts/diff_notes/models/discussion.js b/app/assets/javascripts/diff_notes/models/discussion.js deleted file mode 100644 index 97296a40d6e..00000000000 --- a/app/assets/javascripts/diff_notes/models/discussion.js +++ /dev/null @@ -1,99 +0,0 @@ -/* eslint-disable guard-for-in, no-restricted-syntax */ -/* global NoteModel */ - -import $ from 'jquery'; -import Vue from 'vue'; -import { localTimeAgo } from '../../lib/utils/datetime_utility'; - -class DiscussionModel { - constructor(discussionId) { - this.id = discussionId; - this.notes = {}; - this.loading = false; - this.canResolve = false; - } - - createNote(noteObj) { - Vue.set(this.notes, noteObj.noteId, new NoteModel(this.id, noteObj)); - } - - deleteNote(noteId) { - Vue.delete(this.notes, noteId); - } - - getNote(noteId) { - return this.notes[noteId]; - } - - notesCount() { - return Object.keys(this.notes).length; - } - - isResolved() { - for (const noteId in this.notes) { - const note = this.notes[noteId]; - - if (!note.resolved) { - return false; - } - } - return true; - } - - resolveAllNotes(resolvedBy) { - for (const noteId in this.notes) { - const note = this.notes[noteId]; - - if (!note.resolved) { - note.resolved = true; - note.resolved_by = resolvedBy; - } - } - } - - unResolveAllNotes() { - for (const noteId in this.notes) { - const note = this.notes[noteId]; - - if (note.resolved) { - note.resolved = false; - note.resolved_by = null; - } - } - } - - updateHeadline(data) { - const discussionSelector = `.discussion[data-discussion-id="${this.id}"]`; - const $discussionHeadline = $(`${discussionSelector} .js-discussion-headline`); - - if (data.discussion_headline_html) { - if ($discussionHeadline.length) { - $discussionHeadline.replaceWith(data.discussion_headline_html); - } else { - $(`${discussionSelector} .discussion-header`).append(data.discussion_headline_html); - } - - localTimeAgo($('.js-timeago', `${discussionSelector}`)); - } else { - $discussionHeadline.remove(); - } - } - - isResolvable() { - if (!this.canResolve) { - return false; - } - - for (const noteId in this.notes) { - const note = this.notes[noteId]; - - if (note.canResolve) { - return true; - } - } - - return false; - } -} - -window.DiscussionModel = DiscussionModel; diff --git a/app/assets/javascripts/diff_notes/models/note.js b/app/assets/javascripts/diff_notes/models/note.js deleted file mode 100644 index 825a69deeec..00000000000 --- a/app/assets/javascripts/diff_notes/models/note.js +++ /dev/null @@ -1,14 +0,0 @@ -class NoteModel { - constructor(discussionId, noteObj) { - this.discussionId = discussionId; - this.id = noteObj.noteId; - this.canResolve = noteObj.canResolve; - this.resolved = noteObj.resolved; - this.resolved_by = noteObj.resolvedBy; - this.authorName = noteObj.authorName; - this.authorAvatar = noteObj.authorAvatar; - this.noteTruncated = noteObj.noteTruncated; - } -} - -window.NoteModel = NoteModel; diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js deleted file mode 100644 index d6975963977..00000000000 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ /dev/null @@ -1,86 +0,0 @@ -/* global CommentsStore */ - -import Vue from 'vue'; -import { deprecatedCreateFlash as Flash } from '../../flash'; -import { __ } from '~/locale'; - -window.gl = window.gl || {}; - -class ResolveServiceClass { - constructor(root) { - this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`); - this.discussionResource = Vue.resource( - `${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`, - ); - } - - resolve(noteId) { - return this.noteResource.save({ noteId }, {}); - } - - unresolve(noteId) { - return this.noteResource.delete({ noteId }, {}); - } - - toggleResolveForDiscussion(mergeRequestId, discussionId) { - const discussion = CommentsStore.state[discussionId]; - const isResolved = discussion.isResolved(); - let promise; - - if (isResolved) { - promise = this.unResolveAll(mergeRequestId, discussionId); - } else { - promise = this.resolveAll(mergeRequestId, discussionId); - } - - promise - .then(resp => resp.json()) - .then(data => { - discussion.loading = false; - const resolvedBy = data ? data.resolved_by : null; - - if (isResolved) { - discussion.unResolveAllNotes(); - } else { - discussion.resolveAllNotes(resolvedBy); - } - - if (gl.mrWidget) gl.mrWidget.checkStatus(); - discussion.updateHeadline(data); - }) - .catch( - () => - new Flash(__('An error occurred when trying to resolve a discussion. Please try again.')), - ); - } - - resolveAll(mergeRequestId, discussionId) { - const discussion = CommentsStore.state[discussionId]; - - discussion.loading = true; - - return this.discussionResource.save( - { - mergeRequestId, - discussionId, - }, - {}, - ); - } - - unResolveAll(mergeRequestId, discussionId) { - const discussion = CommentsStore.state[discussionId]; - - discussion.loading = true; - - return this.discussionResource.delete( - { - mergeRequestId, - discussionId, - }, - {}, - ); - } -} - -gl.DiffNotesResolveServiceClass = ResolveServiceClass; diff --git a/app/assets/javascripts/diff_notes/stores/comments.js b/app/assets/javascripts/diff_notes/stores/comments.js deleted file mode 100644 index 9bde18c4edf..00000000000 --- a/app/assets/javascripts/diff_notes/stores/comments.js +++ /dev/null @@ -1,56 +0,0 @@ -/* eslint-disable no-restricted-syntax, guard-for-in */ -/* global DiscussionModel */ - -import Vue from 'vue'; - -window.CommentsStore = { - state: {}, - get(discussionId, noteId) { - return this.state[discussionId].getNote(noteId); - }, - createDiscussion(discussionId, canResolve) { - let discussion = this.state[discussionId]; - if (!this.state[discussionId]) { - discussion = new DiscussionModel(discussionId); - Vue.set(this.state, discussionId, discussion); - } - - if (canResolve !== undefined) { - discussion.canResolve = canResolve; - } - - return discussion; - }, - create(noteObj) { - const discussion = this.createDiscussion(noteObj.discussionId); - - discussion.createNote(noteObj); - }, - update(discussionId, noteId, resolved, resolvedBy) { - const discussion = this.state[discussionId]; - const note = discussion.getNote(noteId); - note.resolved = resolved; - note.resolved_by = resolvedBy; - }, - delete(discussionId, noteId) { - const discussion = this.state[discussionId]; - discussion.deleteNote(noteId); - - if (discussion.notesCount() === 0) { - Vue.delete(this.state, discussionId); - } - }, - unresolvedDiscussionIds() { - const ids = []; - - for (const discussionId in this.state) { - const discussion = this.state[discussionId]; - - if (!discussion.isResolved()) { - ids.push(discussion.id); - } - } - - return ids; - }, -}; diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index dd5addbf1e3..085f951147f 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -474,7 +474,7 @@ export default { <div v-if="showTreeList" :style="{ width: `${treeWidth}px` }" - class="diff-tree-list js-diff-tree-list mr-3" + class="diff-tree-list js-diff-tree-list px-3 pr-md-0" > <panel-resizer :size.sync="treeWidth" @@ -487,7 +487,7 @@ export default { <tree-list :hide-file-stats="hideFileStats" /> </div> <div - class="diff-files-holder" + class="col-12 col-md-auto diff-files-holder" :class="{ [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer, }" diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue index dded3643115..270bbfb99b7 100644 --- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue +++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue @@ -50,7 +50,7 @@ export default { </script> <template> - <div v-if="!isDismissed" data-testid="root" :class="containerClasses"> + <div v-if="!isDismissed" data-testid="root" :class="containerClasses" class="col-12"> <gl-alert :dismissible="true" :title="__('Some changes are not shown')" diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 23669eecce2..1b747fb7f20 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ import { mapActions } from 'vuex'; -import { GlButtonGroup, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButtonGroup, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -122,74 +122,27 @@ export default { </script> <template> - <li :class="{ 'js-toggle-container': collapsible }" class="commit flex-row"> - <div class="d-flex align-items-center align-self-start"> - <input - v-if="isSelectable" - class="mr-2" - type="checkbox" - :checked="checked" - @change="$emit('handleCheckboxChange', $event.target.checked)" - /> - <user-avatar-link - :link-href="authorUrl" - :img-src="authorAvatar" - :img-alt="authorName" - :img-size="40" - class="avatar-cell d-none d-sm-block" - /> - </div> - <div class="commit-detail flex-list"> - <div class="commit-content qa-commit-content"> - <a - :href="commit.commit_url" - class="commit-row-message item-title" - v-html="commit.title_html" - ></a> - - <span class="commit-row-message d-block d-sm-none">· {{ commit.short_id }}</span> - - <gl-button - v-if="commit.description_html && collapsible" - class="js-toggle-button" - size="small" - icon="ellipsis_h" - :aria-label="__('Toggle commit description')" - /> - - <div class="committer"> - <a - :href="authorUrl" - :class="authorClass" - :data-user-id="authorId" - v-text="authorName" - ></a> - {{ s__('CommitWidget|authored') }} - <time-ago-tooltip :time="commit.authored_date" /> - </div> - - <pre - v-if="commit.description_html" - :class="{ 'js-toggle-content': collapsible, 'd-block': !collapsible }" - class="commit-row-description gl-mb-3 text-dark" - v-html="commitDescription" - ></pre> - </div> - <div class="commit-actions flex-row d-none d-sm-flex"> + <li :class="{ 'js-toggle-container': collapsible }" class="commit"> + <div + class="d-block d-sm-flex flex-row-reverse justify-content-between align-items-start flex-lg-row-reverse" + > + <div + class="commit-actions flex-row d-none d-sm-flex align-items-start flex-wrap justify-content-end" + > <div v-if="commit.signature_html" v-html="commit.signature_html"></div> <commit-pipeline-status v-if="commit.pipeline_status_path" :endpoint="commit.pipeline_status_path" - class="d-inline-flex" + class="d-inline-flex mb-2" /> - <div class="commit-sha-group"> - <div class="label label-monospace monospace" v-text="commit.short_id"></div> + <gl-button-group class="gl-ml-4 gl-mb-4" data-testid="commit-sha-group"> + <gl-button label class="gl-font-monospace" v-text="commit.short_id" /> <clipboard-button :text="commit.id" :title="__('Copy commit SHA')" - class="btn btn-default" + class="input-group-text" /> - </div> + </gl-button-group> <div v-if="hasNeighborCommits && glFeatures.mrCommitNeighborNav" class="commit-nav-buttons ml-3" @@ -226,6 +179,62 @@ export default { </gl-button-group> </div> </div> + <div> + <div class="d-flex float-left align-items-center align-self-start"> + <input + v-if="isSelectable" + class="mr-2" + type="checkbox" + :checked="checked" + @change="$emit('handleCheckboxChange', $event.target.checked)" + /> + <user-avatar-link + :link-href="authorUrl" + :img-src="authorAvatar" + :img-alt="authorName" + :img-size="40" + class="avatar-cell d-none d-sm-block" + /> + </div> + <div class="commit-detail flex-list"> + <div class="commit-content qa-commit-content"> + <a + :href="commit.commit_url" + class="commit-row-message item-title" + v-html="commit.title_html" + ></a> + + <span class="commit-row-message d-block d-sm-none">· {{ commit.short_id }}</span> + + <gl-button + v-if="commit.description_html && collapsible" + class="js-toggle-button" + size="small" + icon="ellipsis_h" + :aria-label="__('Toggle commit description')" + /> + + <div class="committer"> + <a + :href="authorUrl" + :class="authorClass" + :data-user-id="authorId" + v-text="authorName" + ></a> + {{ s__('CommitWidget|authored') }} + <time-ago-tooltip :time="commit.authored_date" /> + </div> + </div> + </div> + </div> + </div> + <div> + <pre + v-if="commit.description_html" + :class="{ 'js-toggle-content': collapsible, 'd-block': !collapsible }" + class="commit-row-description gl-mb-3 text-dark" + v-html="commitDescription" + ></pre> </div> </li> </template> diff --git a/app/assets/javascripts/diffs/components/commit_widget.vue b/app/assets/javascripts/diffs/components/commit_widget.vue index 5c7e84bd87c..b1a2b2a72ea 100644 --- a/app/assets/javascripts/diffs/components/commit_widget.vue +++ b/app/assets/javascripts/diffs/components/commit_widget.vue @@ -1,19 +1,6 @@ <script> import CommitItem from './commit_item.vue'; -/** - * CommitWidget - * - * ----------------------------------------------------------------- - * WARNING: Please keep changes up-to-date with the following files: - * - `views/projects/merge_requests/diffs/_commit_widget.html.haml` - * ----------------------------------------------------------------- - * - * This Component was cloned from a HAML view. For the time being, - * they coexist, but there is an issue to remove the duplication. - * https://gitlab.com/gitlab-org/gitlab-foss/issues/51613 - * - */ export default { components: { CommitItem, diff --git a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue index 8263e938e69..adef5d94624 100644 --- a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue +++ b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue @@ -32,7 +32,7 @@ export default { <gl-icon :size="12" name="angle-down" class="position-absolute" /> </a> <div class="dropdown-menu dropdown-select dropdown-menu-selectable"> - <div class="dropdown-content"> + <div class="dropdown-content" data-qa-selector="dropdown_content"> <ul> <li v-for="version in versions" :key="version.id"> <a :class="{ 'is-active': version.selected }" :href="version.href"> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index b94874c5644..b1ebd8e6ebc 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -100,6 +100,7 @@ export default { <compare-dropdown-layout :versions="diffCompareDropdownTargetVersions" class="mr-version-compare-dropdown" + data-qa-selector="target_version_dropdown" /> </template> <template #source> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 9ecb9a44443..e68260b3e62 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -85,11 +85,9 @@ export default { }, }, updated() { - if (window.gon?.features?.codeNavigation) { - this.$nextTick(() => { - eventHub.$emit('showBlobInteractionZones', this.diffFile.new_path); - }); - } + this.$nextTick(() => { + eventHub.$emit('showBlobInteractionZones', this.diffFile.new_path); + }); }, methods: { ...mapActions('diffs', ['saveDiffDiscussion', 'closeDiffFileCommentForm']), diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 02396a4ba1b..529723a349d 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -44,7 +44,7 @@ export default { return { isLoadingCollapsedDiff: false, forkMessageVisible: false, - isCollapsed: this.file.viewer.collapsed || false, + isCollapsed: this.file.viewer.automaticallyCollapsed || false, }; }, computed: { @@ -96,16 +96,16 @@ export default { }, 'file.file_hash': { handler: function watchFileHash() { - if (this.viewDiffsFileByFile && this.file.viewer.collapsed) { + if (this.viewDiffsFileByFile && this.file.viewer.automaticallyCollapsed) { this.isCollapsed = false; this.handleLoadCollapsedDiff(); } else { - this.isCollapsed = this.file.viewer.collapsed || false; + this.isCollapsed = this.file.viewer.automaticallyCollapsed || false; } }, immediate: true, }, - 'file.viewer.collapsed': function setIsCollapsed(newVal) { + 'file.viewer.automaticallyCollapsed': function setIsCollapsed(newVal) { if (!this.viewDiffsFileByFile) { this.isCollapsed = newVal; } diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index fded391cc84..b08b9df13a4 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -1,39 +1,44 @@ <script> -/* eslint-disable vue/no-v-html */ import { escape } from 'lodash'; import { mapActions, mapGetters } from 'vuex'; import { - GlDeprecatedButton, GlTooltipDirective, GlSafeHtmlDirective, - GlLoadingIcon, GlIcon, GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, s__, sprintf } from '~/locale'; import { diffViewerModes } from '~/ide/constants'; -import EditButton from './edit_button.vue'; import DiffStats from './diff_stats.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; +import { DIFF_FILE_HEADER } from '../i18n'; export default { components: { - GlLoadingIcon, - GlDeprecatedButton, ClipboardButton, - EditButton, GlIcon, FileIcon, DiffStats, GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, }, directives: { GlTooltip: GlTooltipDirective, SafeHtml: GlSafeHtmlDirective, }, + i18n: { + ...DIFF_FILE_HEADER, + }, props: { discussionPath: { type: String, @@ -69,6 +74,11 @@ export default { default: false, }, }, + data() { + return { + moreActionsShown: false, + }; + }, computed: { ...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']), diffContentIDSelector() { @@ -128,13 +138,9 @@ export default { }, viewReplacedFileButtonText() { const truncatedBaseSha = escape(truncateSha(this.diffFile.diff_refs.base_sha)); - return sprintf( - s__('MergeRequests|View replaced file @ %{commitId}'), - { - commitId: `<span class="commit-sha">${truncatedBaseSha}</span>`, - }, - false, - ); + return sprintf(s__('MergeRequests|View replaced file @ %{commitId}'), { + commitId: truncatedBaseSha, + }); }, gfmCopyText() { return `\`${this.diffFile.file_path}\``; @@ -151,6 +157,13 @@ export default { } return s__('MRDiff|Show full file'); }, + showEditButton() { + return ( + this.diffFile.blob?.readable_text && + !this.diffFile.deleted_file && + (this.diffFile.edit_path || this.diffFile.ide_edit_path) + ); + }, }, methods: { ...mapActions('diffs', [ @@ -162,8 +175,11 @@ export default { handleToggleFile() { this.$emit('toggleFile'); }, - showForkMessage() { - this.$emit('showForkMessage'); + showForkMessage(e) { + if (this.canCurrentUserFork && !this.diffFile.can_modify_blob) { + e.preventDefault(); + this.$emit('showForkMessage'); + } }, handleFileNameClick(e) { const isLinkToOtherPage = @@ -179,6 +195,9 @@ export default { } } }, + setMoreActionsShown(val) { + this.moreActionsShown = val; + }, }, }; </script> @@ -186,10 +205,11 @@ export default { <template> <div ref="header" + :class="{ 'gl-z-dropdown-menu!': moreActionsShown }" class="js-file-title file-title file-title-flex-parent" @click.self="handleToggleFile" > - <div class="file-header-content"> + <div class="file-header-content gl-display-flex gl-align-items-center gl-pr-0!"> <gl-icon v-if="collapsible" ref="collapseIcon" @@ -202,7 +222,7 @@ export default { <a ref="titleWrapper" :v-once="!viewDiffsFileByFile" - class="gl-mr-2" + class="gl-mr-2 gl-text-decoration-none!" :href="titleLink" @click="handleFileNameClick" > @@ -210,20 +230,27 @@ export default { <span v-if="isFileRenamed"> <strong v-gl-tooltip + v-safe-html="diffFile.old_path_html" :title="diffFile.old_path" class="file-title-name" - v-html="diffFile.old_path_html" ></strong> → <strong v-gl-tooltip + v-safe-html="diffFile.new_path_html" :title="diffFile.new_path" class="file-title-name" - v-html="diffFile.new_path_html" ></strong> </span> - <strong v-else v-gl-tooltip :title="filePath" class="file-title-name" data-container="body"> + <strong + v-else + v-gl-tooltip + :title="filePath" + class="file-title-name" + data-container="body" + data-qa-selector="file_name_content" + > {{ filePath }} </strong> </a> @@ -232,7 +259,8 @@ export default { :title="__('Copy file path')" :text="diffFile.file_path" :gfm="gfmCopyText" - css-class="btn-default btn-transparent btn-clipboard" + data-testid="diff-file-copy-clipboard" + category="tertiary" data-track-event="click_copy_file_button" data-track-label="diff_copy_file_path_button" data-track-property="diff_copy_file" @@ -247,93 +275,93 @@ export default { <div v-if="!diffFile.submodule && addMergeRequestButtons" - class="file-actions d-none d-sm-flex align-items-center flex-wrap" + class="file-actions d-flex align-items-center flex-wrap" > <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> - <div class="btn-group" role="group"> - <template v-if="diffFile.blob && diffFile.blob.readable_text"> - <span v-gl-tooltip.hover :title="s__('MergeRequests|Toggle comments for this file')"> - <gl-deprecated-button - ref="toggleDiscussionsButton" - :disabled="!diffHasDiscussions(diffFile)" - :class="{ active: diffHasExpandedDiscussions(diffFile) }" - class="js-btn-vue-toggle-comments btn" - data-qa-selector="toggle_comments_button" - data-track-event="click_toggle_comments_button" - data-track-label="diff_toggle_comments_button" - data-track-property="diff_toggle_comments" - type="button" - @click="toggleFileDiscussionWrappers(diffFile)" - > - <gl-icon name="comment" /> - </gl-deprecated-button> - </span> - - <edit-button - v-if="!diffFile.deleted_file" - :can-current-user-fork="canCurrentUserFork" - :edit-path="diffFile.edit_path" - :can-modify-blob="diffFile.can_modify_blob" - data-track-event="click_toggle_edit_button" - data-track-label="diff_toggle_edit_button" - data-track-property="diff_toggle_edit" - @showForkMessage="showForkMessage" - /> - </template> - - <a - v-if="diffFile.replaced_view_path" - ref="replacedFileButton" - :href="diffFile.replaced_view_path" - class="btn view-file" - v-html="viewReplacedFileButtonText" - > - </a> - <gl-deprecated-button - v-if="!diffFile.is_fully_expanded" - ref="expandDiffToFullFileButton" - v-gl-tooltip.hover - :title="expandDiffToFullFileTitle" - class="expand-file" - data-track-event="click_toggle_view_full_button" - data-track-label="diff_toggle_view_full_button" - data-track-property="diff_toggle_view_full" - @click="toggleFullDiff(diffFile.file_path)" - > - <gl-loading-icon v-if="diffFile.isLoadingFullFile" color="dark" inline /> - <gl-icon v-else-if="diffFile.isShowingFullFile" name="doc-changes" /> - <gl-icon v-else name="doc-expand" /> - </gl-deprecated-button> - <gl-deprecated-button - ref="viewButton" - v-gl-tooltip.hover - :href="diffFile.view_path" - target="_blank" - class="view-file" - data-track-event="click_toggle_view_sha_button" - data-track-label="diff_toggle_view_sha_button" - data-track-property="diff_toggle_view_sha" - :title="viewFileButtonText" - > - <gl-icon name="doc-text" /> - </gl-deprecated-button> - - <a + <gl-button-group class="gl-pt-0!"> + <gl-button v-if="diffFile.external_url" ref="externalLink" v-gl-tooltip.hover :href="diffFile.external_url" :title="`View on ${diffFile.formatted_external_url}`" target="_blank" - rel="noopener noreferrer" data-track-event="click_toggle_external_button" data-track-label="diff_toggle_external_button" data-track-property="diff_toggle_external" - class="btn btn-file-option" + icon="external-link" + /> + <gl-dropdown + v-gl-tooltip.hover.focus="$options.i18n.optionsDropdownTitle" + right + toggle-class="btn-icon js-diff-more-actions" + class="gl-pt-0!" + @show="setMoreActionsShown(true)" + @hidden="setMoreActionsShown(false)" > - <gl-icon name="external-link" /> - </a> - </div> + <template #button-content> + <gl-icon name="ellipsis_v" class="mr-0" /> + <span class="sr-only">{{ $options.i18n.optionsDropdownTitle }}</span> + </template> + <gl-dropdown-item + v-if="diffFile.replaced_view_path" + ref="replacedFileButton" + :href="diffFile.replaced_view_path" + target="_blank" + > + {{ viewReplacedFileButtonText }} + </gl-dropdown-item> + <gl-dropdown-item ref="viewButton" :href="diffFile.view_path" target="_blank"> + {{ viewFileButtonText }} + </gl-dropdown-item> + <template v-if="showEditButton"> + <gl-dropdown-item + v-if="diffFile.edit_path" + ref="editButton" + :href="diffFile.edit_path" + class="js-edit-blob" + @click="showForkMessage" + > + {{ __('Edit in single-file editor') }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="diffFile.edit_path" + ref="ideEditButton" + :href="diffFile.ide_edit_path" + class="js-ide-edit-blob" + > + {{ __('Edit in Web IDE') }} + </gl-dropdown-item> + </template> + + <template v-if="!diffFile.viewer.automaticallyCollapsed"> + <gl-dropdown-divider + v-if="!diffFile.is_fully_expanded || diffHasDiscussions(diffFile)" + /> + + <gl-dropdown-item + v-if="diffHasDiscussions(diffFile)" + ref="toggleDiscussionsButton" + data-qa-selector="toggle_comments_button" + @click="toggleFileDiscussionWrappers(diffFile)" + > + <template v-if="diffHasExpandedDiscussions(diffFile)"> + {{ __('Hide comments on this file') }} + </template> + <template v-else> + {{ __('Show comments on this file') }} + </template> + </gl-dropdown-item> + <gl-dropdown-item + v-if="!diffFile.is_fully_expanded" + ref="expandDiffToFullFileButton" + @click="toggleFullDiff(diffFile.file_path)" + > + {{ expandDiffToFullFileTitle }} + </gl-dropdown-item> + </template> + </gl-dropdown> + </gl-button-group> </div> <div diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js new file mode 100644 index 00000000000..08b87a4bade --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -0,0 +1,85 @@ +import { __ } from '~/locale'; +import { + MATCH_LINE_TYPE, + CONTEXT_LINE_TYPE, + LINE_HOVER_CLASS_NAME, + OLD_NO_NEW_LINE_TYPE, + NEW_NO_NEW_LINE_TYPE, + EMPTY_CELL_TYPE, +} from '../constants'; + +export const isHighlighted = (state, line, isCommented) => { + if (isCommented) return true; + + const lineCode = line?.line_code; + return lineCode ? lineCode === state.diffs.highlightedRow : false; +}; + +export const isContextLine = type => type === CONTEXT_LINE_TYPE; + +export const isMatchLine = type => type === MATCH_LINE_TYPE; + +export const isMetaLine = type => + [OLD_NO_NEW_LINE_TYPE, NEW_NO_NEW_LINE_TYPE, EMPTY_CELL_TYPE].includes(type); + +export const shouldRenderCommentButton = (isLoggedIn, isCommentButtonRendered) => { + return isCommentButtonRendered && isLoggedIn; +}; + +export const hasDiscussions = line => line?.discussions?.length > 0; + +export const lineHref = line => `#${line?.line_code || ''}`; + +export const lineCode = line => { + if (!line) return undefined; + return line.line_code || line.left?.line_code || line.right?.line_code; +}; + +export const classNameMapCell = (line, hll, isLoggedIn, isHover) => { + if (!line) return []; + const { type } = line; + + return [ + type, + { + hll, + [LINE_HOVER_CLASS_NAME]: isLoggedIn && isHover && !isContextLine(type) && !isMetaLine(type), + }, + ]; +}; + +export const addCommentTooltip = line => { + let tooltip; + if (!line) return tooltip; + + tooltip = __('Add a comment to this line'); + const brokenSymlinks = line.commentsDisabled; + + if (brokenSymlinks) { + if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) { + tooltip = __( + 'Commenting on symbolic links that replace or are replaced by files is currently not supported.', + ); + } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) { + tooltip = __( + 'Commenting on files that replace or are replaced by symbolic links is currently not supported.', + ); + } + } + + return tooltip; +}; + +export const parallelViewLeftLineType = (line, hll) => { + if (line?.right?.type === NEW_NO_NEW_LINE_TYPE) { + return OLD_NO_NEW_LINE_TYPE; + } + + const lineTypeClass = line?.left ? line.left.type : EMPTY_CELL_TYPE; + + return [lineTypeClass, { hll }]; +}; + +export const shouldShowCommentButton = (hover, context, meta, discussions) => { + return hover && !context && !meta && !discussions; +}; diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue index 05fbbd39fae..f229fc4cf60 100644 --- a/app/assets/javascripts/diffs/components/diff_stats.vue +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -42,7 +42,7 @@ export default { class="diff-stats" :class="{ 'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader, - 'd-inline-flex': !isCompareVersionsHeader, + 'd-none d-sm-inline-flex': !isCompareVersionsHeader, }" > <div v-if="hasDiffFiles" class="diff-stats-group"> diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue deleted file mode 100644 index 49982a81372..00000000000 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ /dev/null @@ -1,206 +0,0 @@ -<script> -import { mapGetters, mapActions } from 'vuex'; -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; -import DiffGutterAvatars from './diff_gutter_avatars.vue'; -import { __ } from '~/locale'; -import { - CONTEXT_LINE_TYPE, - LINE_POSITION_RIGHT, - EMPTY_CELL_TYPE, - OLD_NO_NEW_LINE_TYPE, - OLD_LINE_TYPE, - NEW_NO_NEW_LINE_TYPE, - LINE_HOVER_CLASS_NAME, -} from '../constants'; - -export default { - components: { - DiffGutterAvatars, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - line: { - type: Object, - required: true, - }, - fileHash: { - type: String, - required: true, - }, - isHighlighted: { - type: Boolean, - required: true, - }, - showCommentButton: { - type: Boolean, - required: false, - default: false, - }, - linePosition: { - type: String, - required: false, - default: '', - }, - lineType: { - type: String, - required: false, - default: '', - }, - isBottom: { - type: Boolean, - required: false, - default: false, - }, - isHover: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - isCommentButtonRendered: false, - }; - }, - computed: { - ...mapGetters(['isLoggedIn']), - lineCode() { - return ( - this.line.line_code || - (this.line.left && this.line.left.line_code) || - (this.line.right && this.line.right.line_code) - ); - }, - lineHref() { - return `#${this.line.line_code || ''}`; - }, - shouldShowCommentButton() { - return this.isHover && !this.isContextLine && !this.isMetaLine && !this.hasDiscussions; - }, - hasDiscussions() { - return this.line.discussions && this.line.discussions.length > 0; - }, - shouldShowAvatarsOnGutter() { - if (!this.line.type && this.linePosition === LINE_POSITION_RIGHT) { - return false; - } - return this.showCommentButton && this.hasDiscussions; - }, - shouldRenderCommentButton() { - if (!this.isCommentButtonRendered) { - return false; - } - - if (this.isLoggedIn && this.showCommentButton) { - const isDiffHead = parseBoolean(getParameterByName('diff_head')); - return !isDiffHead || gon.features?.mergeRefHeadComments; - } - - return false; - }, - isContextLine() { - return this.line.type === CONTEXT_LINE_TYPE; - }, - isMetaLine() { - const { type } = this.line; - - return ( - type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE - ); - }, - classNameMap() { - const { type } = this.line; - - return [ - type, - { - hll: this.isHighlighted, - [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && this.isHover && !this.isContextLine && !this.isMetaLine, - }, - ]; - }, - lineNumber() { - return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line; - }, - addCommentTooltip() { - const brokenSymlinks = this.line.commentsDisabled; - let tooltip = __('Add a comment to this line'); - - if (brokenSymlinks) { - if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) { - tooltip = __( - 'Commenting on symbolic links that replace or are replaced by files is currently not supported.', - ); - } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) { - tooltip = __( - 'Commenting on files that replace or are replaced by symbolic links is currently not supported.', - ); - } - } - - return tooltip; - }, - }, - mounted() { - this.unwatchShouldShowCommentButton = this.$watch('shouldShowCommentButton', newVal => { - if (newVal) { - this.isCommentButtonRendered = true; - this.unwatchShouldShowCommentButton(); - } - }); - }, - beforeDestroy() { - this.unwatchShouldShowCommentButton(); - }, - methods: { - ...mapActions('diffs', ['showCommentForm', 'setHighlightedRow', 'toggleLineDiscussions']), - handleCommentButton() { - this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash }); - }, - }, -}; -</script> - -<template> - <td ref="td" :class="classNameMap"> - <span - ref="addNoteTooltip" - v-gl-tooltip - class="add-diff-note tooltip-wrapper" - :title="addCommentTooltip" - > - <button - v-if="shouldRenderCommentButton" - v-show="shouldShowCommentButton" - ref="addDiffNoteButton" - type="button" - class="add-diff-note note-button js-add-diff-note-button qa-diff-comment" - :disabled="line.commentsDisabled" - @click="handleCommentButton" - > - <gl-icon :size="12" name="comment" /> - </button> - </span> - <a - v-if="lineNumber" - ref="lineNumberRef" - :data-linenumber="lineNumber" - :href="lineHref" - @click="setHighlightedRow(lineCode)" - > - </a> - <diff-gutter-avatars - v-if="shouldShowAvatarsOnGutter" - :discussions="line.discussions" - :discussions-expanded="line.discussionsExpanded" - @toggleLineDiscussions=" - toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded }) - " - /> - </td> -</template> diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue deleted file mode 100644 index ff1af5569dc..00000000000 --- a/app/assets/javascripts/diffs/components/edit_button.vue +++ /dev/null @@ -1,64 +0,0 @@ -<script> -import { GlTooltipDirective, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { - GlDeprecatedButton, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - editPath: { - type: String, - required: false, - default: '', - }, - canCurrentUserFork: { - type: Boolean, - required: true, - }, - canModifyBlob: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - tooltipTitle() { - if (this.isDisabled) { - return __("Can't edit as source branch was deleted"); - } - - return __('Edit file'); - }, - isDisabled() { - return !this.editPath; - }, - }, - methods: { - handleEditClick(evt) { - if (this.canCurrentUserFork && !this.canModifyBlob) { - evt.preventDefault(); - this.$emit('showForkMessage'); - } - }, - }, -}; -</script> - -<template> - <span v-gl-tooltip.top :title="tooltipTitle"> - <gl-deprecated-button - :href="editPath" - :disabled="isDisabled" - :class="{ 'cursor-not-allowed': isDisabled }" - class="rounded-0 js-edit-blob" - @click.native="handleEditClick" - > - <gl-icon name="pencil" /> - </gl-deprecated-button> - </span> -</template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index 7fab750089e..99cf79a70d4 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -1,22 +1,9 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import { - MATCH_LINE_TYPE, - NEW_LINE_TYPE, - OLD_LINE_TYPE, - CONTEXT_LINE_TYPE, - CONTEXT_LINE_CLASS_NAME, - LINE_POSITION_LEFT, - LINE_POSITION_RIGHT, - LINE_HOVER_CLASS_NAME, - OLD_NO_NEW_LINE_TYPE, - NEW_NO_NEW_LINE_TYPE, - EMPTY_CELL_TYPE, -} from '../constants'; -import { __ } from '~/locale'; -import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; +import { CONTEXT_LINE_CLASS_NAME } from '../constants'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; +import * as utils from './diff_row_utils'; export default { components: { @@ -61,14 +48,11 @@ export default { ...mapGetters('diffs', ['fileLineCoverage']), ...mapState({ isHighlighted(state) { - if (this.isCommented) return true; - - const lineCode = this.line.line_code; - return lineCode ? lineCode === state.diffs.highlightedRow : false; + return utils.isHighlighted(state, this.line, this.isCommented); }, }), isContextLine() { - return this.line.type === CONTEXT_LINE_TYPE; + return utils.isContextLine(this.line.type); }, classNameMap() { return [ @@ -82,82 +66,44 @@ export default { return this.line.line_code || `${this.fileHash}_${this.line.old_line}_${this.line.new_line}`; }, isMatchLine() { - return this.line.type === MATCH_LINE_TYPE; + return utils.isMatchLine(this.line.type); }, coverageState() { return this.fileLineCoverage(this.filePath, this.line.new_line); }, isMetaLine() { - const { type } = this.line; - - return ( - type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE - ); + return utils.isMetaLine(this.line.type); }, classNameMapCell() { - const { type } = this.line; - - return [ - type, - { - hll: this.isHighlighted, - [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && this.isHover && !this.isContextLine && !this.isMetaLine, - }, - ]; + return utils.classNameMapCell(this.line, this.isHighlighted, this.isLoggedIn, this.isHover); }, addCommentTooltip() { - const brokenSymlinks = this.line.commentsDisabled; - let tooltip = __('Add a comment to this line'); - - if (brokenSymlinks) { - if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) { - tooltip = __( - 'Commenting on symbolic links that replace or are replaced by files is currently not supported.', - ); - } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) { - tooltip = __( - 'Commenting on files that replace or are replaced by symbolic links is currently not supported.', - ); - } - } - - return tooltip; + return utils.addCommentTooltip(this.line); }, shouldRenderCommentButton() { - if (this.isLoggedIn) { - const isDiffHead = parseBoolean(getParameterByName('diff_head')); - return !isDiffHead || gon.features?.mergeRefHeadComments; - } - - return false; + return utils.shouldRenderCommentButton(this.isLoggedIn, true); }, shouldShowCommentButton() { - return this.isHover && !this.isContextLine && !this.isMetaLine && !this.hasDiscussions; + return utils.shouldShowCommentButton( + this.isHover, + this.isContextLine, + this.isMetaLine, + this.hasDiscussions, + ); }, hasDiscussions() { - return this.line.discussions && this.line.discussions.length > 0; + return utils.hasDiscussions(this.line); }, lineHref() { - return `#${this.line.line_code || ''}`; + return utils.lineHref(this.line); }, lineCode() { - return ( - this.line.line_code || - (this.line.left && this.line.left.line_code) || - (this.line.right && this.line.right.line_code) - ); + return utils.lineCode(this.line); }, shouldShowAvatarsOnGutter() { return this.hasDiscussions; }, }, - created() { - this.newLineType = NEW_LINE_TYPE; - this.oldLineType = OLD_LINE_TYPE; - this.linePositionLeft = LINE_POSITION_LEFT; - this.linePositionRight = LINE_POSITION_RIGHT; - }, mounted() { this.scrollToLineIfNeededInline(this.line); }, @@ -242,6 +188,7 @@ export default { class="line-coverage" ></td> <td + :key="line.line_code" v-safe-html="line.rich_text" :class="[ line.type, diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue index b525490f7cc..127e3f214cf 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -113,8 +113,8 @@ export default { }, methods: { ...mapActions('diffs', ['showCommentForm']), - showNewDiscussionForm() { - this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.diffFileHash }); + showNewDiscussionForm(lineCode) { + this.showCommentForm({ lineCode, fileHash: this.diffFileHash }); }, }, }; @@ -134,7 +134,7 @@ export default { v-if="!hasDraftLeft" :has-form="showLeftSideCommentForm" :render-reply-placeholder="shouldRenderReplyPlaceholderOnLeft" - @showNewDiscussionForm="showNewDiscussionForm" + @showNewDiscussionForm="showNewDiscussionForm(line.left.line_code)" > <template #form> <diff-line-note-form @@ -159,7 +159,7 @@ export default { v-if="!hasDraftRight" :has-form="showRightSideCommentForm" :render-reply-placeholder="shouldRenderReplyPlaceholderOnRight" - @showNewDiscussionForm="showNewDiscussionForm" + @showNewDiscussionForm="showNewDiscussionForm(line.right.line_code)" > <template #form> <diff-line-note-form diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index 0bf47dc77a6..cdc6db791f0 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -2,21 +2,9 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import $ from 'jquery'; import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import { - MATCH_LINE_TYPE, - NEW_LINE_TYPE, - OLD_LINE_TYPE, - CONTEXT_LINE_TYPE, - CONTEXT_LINE_CLASS_NAME, - OLD_NO_NEW_LINE_TYPE, - PARALLEL_DIFF_VIEW_TYPE, - NEW_NO_NEW_LINE_TYPE, - EMPTY_CELL_TYPE, - LINE_HOVER_CLASS_NAME, -} from '../constants'; -import { __ } from '~/locale'; -import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; +import { CONTEXT_LINE_CLASS_NAME, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; +import * as utils from './diff_row_utils'; export default { components: { @@ -63,20 +51,15 @@ export default { ...mapGetters(['isLoggedIn']), ...mapState({ isHighlighted(state) { - if (this.isCommented) return true; - - const lineCode = - (this.line.left && this.line.left.line_code) || - (this.line.right && this.line.right.line_code); - - return lineCode ? lineCode === state.diffs.highlightedRow : false; + const line = this.line.left?.line_code ? this.line.left : this.line.right; + return utils.isHighlighted(state, line, this.isCommented); }, }), isContextLineLeft() { - return this.line.left && this.line.left.type === CONTEXT_LINE_TYPE; + return utils.isContextLine(this.line.left?.type); }, isContextLineRight() { - return this.line.right && this.line.right.type === CONTEXT_LINE_TYPE; + return utils.isContextLine(this.line.right?.type); }, classNameMap() { return { @@ -85,157 +68,80 @@ export default { }; }, parallelViewLeftLineType() { - if (this.line.right && this.line.right.type === NEW_NO_NEW_LINE_TYPE) { - return OLD_NO_NEW_LINE_TYPE; - } - - const lineTypeClass = this.line.left ? this.line.left.type : EMPTY_CELL_TYPE; - - return [ - lineTypeClass, - { - hll: this.isHighlighted, - }, - ]; + return utils.parallelViewLeftLineType(this.line, this.isHighlighted); }, isMatchLineLeft() { - return this.line.left && this.line.left.type === MATCH_LINE_TYPE; + return utils.isMatchLine(this.line.left?.type); }, isMatchLineRight() { - return this.line.right && this.line.right.type === MATCH_LINE_TYPE; + return utils.isMatchLine(this.line.right?.type); }, coverageState() { return this.fileLineCoverage(this.filePath, this.line.right.new_line); }, classNameMapCellLeft() { - const { type } = this.line.left; - - return [ - type, - { - hll: this.isHighlighted, - [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && this.isLeftHover && !this.isContextLineLeft && !this.isMetaLineLeft, - }, - ]; + return utils.classNameMapCell( + this.line.left, + this.isHighlighted, + this.isLoggedIn, + this.isLeftHover, + ); }, classNameMapCellRight() { - const { type } = this.line.right; - - return [ - type, - { - hll: this.isHighlighted, - [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && - this.isRightHover && - !this.isContextLineRight && - !this.isMetaLineRight, - }, - ]; + return utils.classNameMapCell( + this.line.right, + this.isHighlighted, + this.isLoggedIn, + this.isRightHover, + ); }, addCommentTooltipLeft() { - const brokenSymlinks = this.line.left.commentsDisabled; - let tooltip = __('Add a comment to this line'); - - if (brokenSymlinks) { - if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) { - tooltip = __( - 'Commenting on symbolic links that replace or are replaced by files is currently not supported.', - ); - } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) { - tooltip = __( - 'Commenting on files that replace or are replaced by symbolic links is currently not supported.', - ); - } - } - - return tooltip; + return utils.addCommentTooltip(this.line.left); }, addCommentTooltipRight() { - const brokenSymlinks = this.line.right.commentsDisabled; - let tooltip = __('Add a comment to this line'); - - if (brokenSymlinks) { - if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) { - tooltip = __( - 'Commenting on symbolic links that replace or are replaced by files is currently not supported.', - ); - } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) { - tooltip = __( - 'Commenting on files that replace or are replaced by symbolic links is currently not supported.', - ); - } - } - - return tooltip; + return utils.addCommentTooltip(this.line.right); }, shouldRenderCommentButton() { - if (!this.isCommentButtonRendered) { - return false; - } - - if (this.isLoggedIn) { - const isDiffHead = parseBoolean(getParameterByName('diff_head')); - return !isDiffHead || gon.features?.mergeRefHeadComments; - } - - return false; + return utils.shouldRenderCommentButton(this.isLoggedIn, this.isCommentButtonRendered); }, shouldShowCommentButtonLeft() { - return ( - this.isLeftHover && - !this.isContextLineLeft && - !this.isMetaLineLeft && - !this.hasDiscussionsLeft + return utils.shouldShowCommentButton( + this.isLeftHover, + this.isContextLineLeft, + this.isMetaLineLeft, + this.hasDiscussionsLeft, ); }, shouldShowCommentButtonRight() { - return ( - this.isRightHover && - !this.isContextLineRight && - !this.isMetaLineRight && - !this.hasDiscussionsRight + return utils.shouldShowCommentButton( + this.isRightHover, + this.isContextLineRight, + this.isMetaLineRight, + this.hasDiscussionsRight, ); }, hasDiscussionsLeft() { - return this.line.left?.discussions?.length > 0; + return utils.hasDiscussions(this.line.left); }, hasDiscussionsRight() { - return this.line.right?.discussions?.length > 0; + return utils.hasDiscussions(this.line.right); }, lineHrefOld() { - return `#${this.line.left.line_code || ''}`; + return utils.lineHref(this.line.left); }, lineHrefNew() { - return `#${this.line.right.line_code || ''}`; + return utils.lineHref(this.line.right); }, lineCode() { - return ( - (this.line.left && this.line.left.line_code) || - (this.line.right && this.line.right.line_code) - ); + return utils.lineCode(this.line); }, isMetaLineLeft() { - const type = this.line.left?.type; - - return ( - type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE - ); + return utils.isMetaLine(this.line.left?.type); }, isMetaLineRight() { - const type = this.line.right?.type; - - return ( - type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE - ); + return utils.isMetaLine(this.line.right?.type); }, }, - created() { - this.newLineType = NEW_LINE_TYPE; - this.oldLineType = OLD_LINE_TYPE; - this.parallelDiffViewType = PARALLEL_DIFF_VIEW_TYPE; - }, mounted() { this.scrollToLineIfNeededParallel(this.line); this.unwatchShouldShowCommentButton = this.$watch( @@ -341,6 +247,7 @@ export default { <td :class="parallelViewLeftLineType" class="line-coverage left-side"></td> <td :id="line.left.line_code" + :key="line.left.line_code" v-safe-html="line.left.rich_text" :class="parallelViewLeftLineType" class="line_content with-coverage parallel left-side" @@ -401,6 +308,7 @@ export default { ></td> <td :id="line.right.line_code" + :key="line.right.rich_text" v-safe-html="line.right.rich_text" :class="[ line.right.type, diff --git a/app/assets/javascripts/diffs/diff_file.js b/app/assets/javascripts/diffs/diff_file.js index 610b71235d9..933197a2c7f 100644 --- a/app/assets/javascripts/diffs/diff_file.js +++ b/app/assets/javascripts/diffs/diff_file.js @@ -18,9 +18,21 @@ function fileSymlinkInformation(file, fileList) { ); } +function collapsed(file) { + const viewer = file.viewer || {}; + + return { + automaticallyCollapsed: viewer.automaticallyCollapsed || viewer.collapsed || false, + }; +} + export function prepareRawDiffFile({ file, allFiles }) { Object.assign(file, { brokenSymlink: fileSymlinkInformation(file, allFiles), + viewer: { + ...file.viewer, + ...collapsed(file), + }, }); return file; diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js new file mode 100644 index 00000000000..8699cd88a18 --- /dev/null +++ b/app/assets/javascripts/diffs/i18n.js @@ -0,0 +1,5 @@ +import { __ } from '~/locale'; + +export const DIFF_FILE_HEADER = { + optionsDropdownTitle: __('Options'), +}; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 0f275f1cb3e..966b706fc31 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -103,7 +103,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { commit(types.VIEW_DIFF_FILE, state.diffFiles[0].file_hash); } - if (gon.features?.codeNavigation) { + if (state.diffFiles?.length) { // eslint-disable-next-line promise/catch-or-return,promise/no-nesting import('~/code_navigation').then(m => m.default({ @@ -236,7 +236,7 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi commit(types.RENDER_FILE, file); } - if (file.viewer.collapsed) { + if (file.viewer.automaticallyCollapsed) { eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`); scrollToElement(document.getElementById(file.file_hash)); } else { @@ -252,7 +252,8 @@ export const startRenderDiffsQueue = ({ state, commit }) => { const nextFile = state.diffFiles.find( file => !file.renderIt && - (file.viewer && (!file.viewer.collapsed || file.viewer.name !== diffViewerModes.text)), + (file.viewer && + (!file.viewer.automaticallyCollapsed || file.viewer.name !== diffViewerModes.text)), ); if (nextFile) { @@ -631,7 +632,7 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d filePath: diffFile.file_path, viewer: { ...diffFile.alternate_viewer, - collapsed: false, + automaticallyCollapsed: false, }, }); commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines }); diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 42df5873a41..91425c7825b 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -9,7 +9,7 @@ export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE; export const hasCollapsedFile = state => - state.diffFiles.some(file => file.viewer && file.viewer.collapsed); + state.diffFiles.some(file => file.viewer && file.viewer.automaticallyCollapsed); export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null); @@ -46,15 +46,24 @@ export const diffHasAllCollapsedDiscussions = (state, getters) => diff => { * @param {Object} diff * @returns {Boolean} */ -export const diffHasExpandedDiscussions = (state, getters) => diff => { - const discussions = getters.getDiffFileDiscussions(diff); - - return ( - (discussions && - discussions.length && - discussions.find(discussion => discussion.expanded) !== undefined) || - false - ); +export const diffHasExpandedDiscussions = state => diff => { + const lines = { + [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [], + [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => { + if (line.left) { + acc.push(line.left); + } + + if (line.right) { + acc.push(line.right); + } + + return acc; + }, []), + }; + return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType] + .filter(l => l.discussions.length >= 1) + .some(l => l.discussionsExpanded); }; /** @@ -62,8 +71,25 @@ export const diffHasExpandedDiscussions = (state, getters) => diff => { * @param {Boolean} diff * @returns {Boolean} */ -export const diffHasDiscussions = (state, getters) => diff => - getters.getDiffFileDiscussions(diff).length > 0; +export const diffHasDiscussions = state => diff => { + const lines = { + [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [], + [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => { + if (line.left) { + acc.push(line.left); + } + + if (line.right) { + acc.push(line.right); + } + + return acc; + }, []), + }; + return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType].some( + l => l.discussions.length >= 1, + ); +}; /** * Returns an array with the discussions of the given diff diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 7925c620c4e..13ecf6a997d 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -172,7 +172,7 @@ export default { state.diffFiles.forEach(file => { Object.assign(file, { viewer: Object.assign(file.viewer, { - collapsed: false, + automaticallyCollapsed: false, }), }); }); @@ -355,7 +355,7 @@ export default { const file = state.diffFiles.find(f => f.file_path === filePath); if (file && file.viewer) { - file.viewer.collapsed = collapsed; + file.viewer.automaticallyCollapsed = collapsed; } }, [types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) { diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index 9ee692e953a..b02eb37206a 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -5,3 +5,4 @@ export const EDITOR_LITE_INSTANCE_ERROR_NO_EL = __( ); export const URI_PREFIX = 'gitlab'; +export const CONTENT_UPDATE_DEBOUNCE = 250; diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js index bbd5461ae4d..e52e64d4c2d 100644 --- a/app/assets/javascripts/editor/editor_lite.js +++ b/app/assets/javascripts/editor/editor_lite.js @@ -39,6 +39,26 @@ export default class Editor { monacoEditor.setModelLanguage(model, id); } + static pushToImportsArray(arr, toImport) { + arr.push(import(toImport)); + } + + static loadExtensions(extensions) { + if (!extensions) { + return Promise.resolve(); + } + const promises = []; + const extensionsArray = typeof extensions === 'string' ? extensions.split(',') : extensions; + + extensionsArray.forEach(ext => { + const prefix = ext.includes('/') ? '' : 'editor/'; + const trimmedExt = ext.replace(/^\//, '').trim(); + Editor.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`); + }); + + return Promise.all(promises); + } + /** * Creates a monaco instance with the given options. * @@ -53,6 +73,7 @@ export default class Editor { blobPath = '', blobContent = '', blobGlobalId = '', + extensions = [], ...instanceOptions } = {}) { if (!el) { @@ -80,6 +101,22 @@ export default class Editor { model.dispose(); }); instance.updateModelLanguage = path => Editor.updateModelLanguage(path, instance); + instance.use = args => this.use(args, instance); + + Editor.loadExtensions(extensions, instance) + .then(modules => { + if (modules) { + modules.forEach(module => { + instance.use(module.default); + }); + } + }) + .then(() => { + el.dispatchEvent(new Event('editor-ready')); + }) + .catch(e => { + throw e; + }); this.instances.push(instance); return instance; diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index 4567c807c40..4a56843c0b5 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -1,53 +1,57 @@ -import { uniq } from 'lodash'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; import emojiAliases from 'emojis/aliases.json'; import axios from '../lib/utils/axios_utils'; - import AccessorUtilities from '../lib/utils/accessor'; let emojiMap = null; -let emojiPromise = null; let validEmojiNames = null; export const EMOJI_VERSION = '1'; const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); -export function initEmojiMap() { - emojiPromise = - emojiPromise || - new Promise((resolve, reject) => { - if (emojiMap) { - resolve(emojiMap); - } else if ( - isLocalStorageAvailable && - window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION && - window.localStorage.getItem('gl-emoji-map') - ) { - emojiMap = JSON.parse(window.localStorage.getItem('gl-emoji-map')); - validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; - resolve(emojiMap); - } else { - // We load the JSON file direct from the server - // because it can't be loaded from a CDN due to - // cross domain problems with JSON - axios - .get(`${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`) - .then(({ data }) => { - emojiMap = data; - validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; - resolve(emojiMap); - if (isLocalStorageAvailable) { - window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION); - window.localStorage.setItem('gl-emoji-map', JSON.stringify(emojiMap)); - } - }) - .catch(err => { - reject(err); - }); - } - }); +async function loadEmoji() { + if ( + isLocalStorageAvailable && + window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION && + window.localStorage.getItem('gl-emoji-map') + ) { + return JSON.parse(window.localStorage.getItem('gl-emoji-map')); + } + + // We load the JSON file direct from the server + // because it can't be loaded from a CDN due to + // cross domain problems with JSON + const { data } = await axios.get( + `${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`, + ); + window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION); + window.localStorage.setItem('gl-emoji-map', JSON.stringify(data)); + return data; +} + +async function prepareEmojiMap() { + emojiMap = await loadEmoji(); + + validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; + + Object.keys(emojiMap).forEach(name => { + emojiMap[name].aliases = []; + emojiMap[name].name = name; + }); + Object.entries(emojiAliases).forEach(([alias, name]) => { + // This check, `if (name in emojiMap)` is necessary during testing. In + // production, it shouldn't be necessary, because at no point should there + // be an entry in aliases.json with no corresponding entry in emojis.json. + // However, during testing, the endpoint for emojis.json is mocked with a + // small dataset, whereas aliases.json is always `import`ed directly. + if (name in emojiMap) emojiMap[name].aliases.push(alias); + }); +} - return emojiPromise; +export function initEmojiMap() { + initEmojiMap.promise = initEmojiMap.promise || prepareEmojiMap(); + return initEmojiMap.promise; } export function normalizeEmojiName(name) { @@ -62,13 +66,148 @@ export function isEmojiNameValid(name) { return validEmojiNames.indexOf(name) >= 0; } -export function filterEmojiNames(filter) { - const match = filter.toLowerCase(); - return validEmojiNames.filter(name => name.indexOf(match) >= 0); +export function getAllEmoji() { + return emojiMap; +} + +/** + * Retrieves an emoji by name or alias. + * + * Note: `initEmojiMap` must have been called and completed before this method + * can safely be called. + * + * @param {String} query The emoji name + * @param {Boolean} fallback If true, a fallback emoji will be returned if the + * named emoji does not exist. Defaults to false. + * @returns {Object} The matching emoji. + */ +export function getEmoji(query, fallback = false) { + // TODO https://gitlab.com/gitlab-org/gitlab/-/issues/268208 + const fallbackEmoji = emojiMap.grey_question; + if (!query) { + return fallback ? fallbackEmoji : null; + } + + if (!emojiMap) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('The emoji map is uninitialized or initialization has not completed'); + } + + const lowercaseQuery = query.toLowerCase(); + const name = normalizeEmojiName(lowercaseQuery); + + if (name in emojiMap) { + return emojiMap[name]; + } + + return fallback ? fallbackEmoji : null; } -export function filterEmojiNamesByAlias(filter) { - return uniq(filterEmojiNames(filter).map(name => normalizeEmojiName(name))); +const searchMatchers = { + // Fuzzy matching compares using a fuzzy matching library + fuzzy: (value, query) => { + const score = fuzzaldrinPlus.score(value, query) > 0; + return { score, success: score > 0 }; + }, + // Contains matching compares by indexOf + contains: (value, query) => { + const index = value.indexOf(query.toLowerCase()); + return { index, success: index >= 0 }; + }, + // Exact matching compares by equality + exact: (value, query) => { + return { success: value === query.toLowerCase() }; + }, +}; + +const searchPredicates = { + // Search by name + name: (matcher, query) => emoji => { + const m = matcher(emoji.name, query); + return [{ ...m, emoji, field: emoji.name }]; + }, + // Search by alias + alias: (matcher, query) => emoji => + emoji.aliases.map(alias => { + const m = matcher(alias, query); + return { ...m, emoji, field: alias }; + }), + // Search by description + description: (matcher, query) => emoji => { + const m = matcher(emoji.d, query); + return [{ ...m, emoji, field: emoji.d }]; + }, + // Search by unicode value (always exact) + unicode: (matcher, query) => emoji => { + return [{ emoji, field: emoji.e, success: emoji.e === query }]; + }, +}; + +/** + * Searches emoji by name, aliases, description, and unicode value and returns + * an array of matches. + * + * Behavior is undefined if `opts.fields` is empty or if `opts.match` is fuzzy + * and the query is empty. + * + * Note: `initEmojiMap` must have been called and completed before this method + * can safely be called. + * + * @param {String} query Search query. + * @param {Object} opts Search options (optional). + * @param {String[]} opts.fields Fields to search. Choices are 'name', 'alias', + * 'description', and 'unicode' (value). Default is all (four) fields. + * @param {String} opts.match Search method to use. Choices are 'exact', + * 'contains', or 'fuzzy'. All methods are case-insensitive. Exact matching (the + * default) compares by equality. Contains matching compares by indexOf. Fuzzy + * matching compares using a fuzzy matching library. + * @param {Boolean} opts.fallback If true, a fallback emoji will be returned if + * the result set is empty. Defaults to false. + * @param {Boolean} opts.raw Returns the raw match data instead of just the + * matching emoji. + * @returns {Object[]} A list of emoji that match the query. + */ +export function searchEmoji(query, opts) { + if (!emojiMap) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('The emoji map is uninitialized or initialization has not completed'); + } + + const { + fields = ['name', 'alias', 'description', 'unicode'], + match = 'exact', + fallback = false, + raw = false, + } = opts || {}; + + const fallbackEmoji = emojiMap.grey_question; + if (!query) { + if (fallback) { + return raw ? [{ emoji: fallbackEmoji }] : [fallbackEmoji]; + } + + return []; + } + + // optimization for an exact match in name and alias + if (match === 'exact' && new Set([...fields, 'name', 'alias']).size === 2) { + const emoji = getEmoji(query, fallback); + return emoji ? [emoji] : []; + } + + const matcher = searchMatchers[match] || searchMatchers.exact; + const predicates = fields.map(f => searchPredicates[f](matcher, query)); + + const results = Object.values(emojiMap) + .flatMap(emoji => predicates.flatMap(predicate => predicate(emoji))) + .filter(r => r.success); + + // Fallback to question mark for unknown emojis + if (fallback && results.length === 0) { + return raw ? [{ emoji: fallbackEmoji }] : [fallbackEmoji]; + } + + return raw ? results : results.map(r => r.emoji); } let emojiCategoryMap; @@ -95,16 +234,10 @@ export function getEmojiCategoryMap() { } export function getEmojiInfo(query) { - let name = normalizeEmojiName(query); - let emojiInfo = emojiMap[name]; - - // Fallback to question mark for unknown emojis - if (!emojiInfo) { - name = 'grey_question'; - emojiInfo = emojiMap[name]; - } - - return { ...emojiInfo, name }; + return searchEmoji(query, { + fields: ['name', 'alias'], + fallback: true, + })[0]; } export function emojiFallbackImageSrc(inputName) { diff --git a/app/assets/javascripts/emoji/support/index.js b/app/assets/javascripts/emoji/support/index.js index 1f7852dd487..14b80be9b43 100644 --- a/app/assets/javascripts/emoji/support/index.js +++ b/app/assets/javascripts/emoji/support/index.js @@ -5,6 +5,14 @@ import getUnicodeSupportMap from './unicode_support_map'; let browserUnicodeSupportMap; export default function isEmojiUnicodeSupportedByBrowser(emojiUnicode, unicodeVersion) { + // Skipping the map creation for Bots + RSPec + if ( + navigator.userAgent.indexOf('HeadlessChrome') > -1 || + navigator.userAgent.indexOf('Lighthouse') > -1 || + navigator.userAgent.indexOf('Speedindex') > -1 + ) { + return true; + } browserUnicodeSupportMap = browserUnicodeSupportMap || getUnicodeSupportMap(); return isEmojiUnicodeSupported(browserUnicodeSupportMap, emojiUnicode, unicodeVersion); } diff --git a/app/assets/javascripts/environments/components/enable_review_app_button.vue b/app/assets/javascripts/environments/components/enable_review_app_button.vue deleted file mode 100644 index 8fbbc5189bf..00000000000 --- a/app/assets/javascripts/environments/components/enable_review_app_button.vue +++ /dev/null @@ -1,109 +0,0 @@ -<script> -import { GlButton, GlModal, GlModalDirective, GlLink, GlSprintf } from '@gitlab/ui'; -import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; -import { s__ } from '~/locale'; - -export default { - components: { - GlButton, - GlLink, - GlModal, - GlSprintf, - ModalCopyButton, - }, - directives: { - 'gl-modal': GlModalDirective, - }, - instructionText: { - step1: s__( - 'EnableReviewApp|%{stepStart}Step 1%{stepEnd}. Ensure you have Kubernetes set up and have a base domain for your %{linkStart}cluster%{linkEnd}.', - ), - step2: s__('EnableReviewApp|%{stepStart}Step 2%{stepEnd}. Copy the following snippet:'), - step3: s__( - `EnableReviewApp|%{stepStart}Step 3%{stepEnd}. Add it to the project %{linkStart}gitlab-ci.yml%{linkEnd} file.`, - ), - }, - modalInfo: { - closeText: s__('EnableReviewApp|Close'), - copyToClipboardText: s__('EnableReviewApp|Copy snippet text'), - copyString: `deploy_review: - stage: deploy - script: - - echo "Deploy a review app" - environment: - name: review/$CI_COMMIT_REF_NAME - url: https://$CI_ENVIRONMENT_SLUG.example.com - only: - - branches - except: - - master`, - id: 'enable-review-app-info', - title: s__('ReviewApp|Enable Review App'), - }, -}; -</script> -<template> - <div> - <gl-button - v-gl-modal="$options.modalInfo.id" - variant="info" - category="secondary" - type="button" - class="js-enable-review-app-button" - > - {{ s__('Environments|Enable review app') }} - </gl-button> - <gl-modal - :modal-id="$options.modalInfo.id" - :title="$options.modalInfo.title" - size="lg" - class="text-2 ws-normal" - ok-only - ok-variant="light" - :ok-title="$options.modalInfo.closeText" - > - <p> - <gl-sprintf :message="$options.instructionText.step1"> - <template #step="{ content }"> - <strong>{{ content }}</strong> - </template> - <template #link="{ content }"> - <gl-link - href="https://docs.gitlab.com/ee/user/project/clusters/add_remove_clusters.html" - target="_blank" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> - </p> - <div> - <p> - <gl-sprintf :message="$options.instructionText.step2"> - <template #step="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </p> - <div class="flex align-items-start"> - <pre class="w-100"> {{ $options.modalInfo.copyString }} </pre> - <modal-copy-button - :title="$options.modalInfo.copyToClipboardText" - :text="$options.modalInfo.copyString" - :modal-id="$options.modalInfo.id" - css-classes="border-0" - /> - </div> - </div> - <p> - <gl-sprintf :message="$options.instructionText.step3"> - <template #step="{ content }"> - <strong>{{ content }}</strong> - </template> - <template #link="{ content }"> - <gl-link href="blob/master/.gitlab-ci.yml" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - </gl-modal> - </div> -</template> diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue new file mode 100644 index 00000000000..3dd1d5a1bcc --- /dev/null +++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue @@ -0,0 +1,98 @@ +<script> +import { GlLink, GlModal, GlSprintf } from '@gitlab/ui'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import { s__ } from '~/locale'; + +export default { + components: { + GlLink, + GlModal, + GlSprintf, + ModalCopyButton, + }, + props: { + modalId: { + type: String, + required: true, + }, + }, + instructionText: { + step1: s__( + 'EnableReviewApp|%{stepStart}Step 1%{stepEnd}. Ensure you have Kubernetes set up and have a base domain for your %{linkStart}cluster%{linkEnd}.', + ), + step2: s__('EnableReviewApp|%{stepStart}Step 2%{stepEnd}. Copy the following snippet:'), + step3: s__( + `EnableReviewApp|%{stepStart}Step 3%{stepEnd}. Add it to the project %{linkStart}gitlab-ci.yml%{linkEnd} file.`, + ), + }, + modalInfo: { + closeText: s__('EnableReviewApp|Close'), + copyToClipboardText: s__('EnableReviewApp|Copy snippet text'), + copyString: `deploy_review: + stage: deploy + script: + - echo "Deploy a review app" + environment: + name: review/$CI_COMMIT_REF_NAME + url: https://$CI_ENVIRONMENT_SLUG.example.com + only: + - branches + except: + - master`, + title: s__('ReviewApp|Enable Review App'), + }, +}; +</script> +<template> + <gl-modal + :modal-id="modalId" + :title="$options.modalInfo.title" + size="lg" + ok-only + ok-variant="light" + :ok-title="$options.modalInfo.closeText" + > + <p> + <gl-sprintf :message="$options.instructionText.step1"> + <template #step="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #link="{ content }"> + <gl-link + href="https://docs.gitlab.com/ee/user/project/clusters/add_remove_clusters.html" + target="_blank" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </p> + <div> + <p> + <gl-sprintf :message="$options.instructionText.step2"> + <template #step="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + <div class="gl-display-flex align-items-start"> + <pre class="gl-w-full"> {{ $options.modalInfo.copyString }} </pre> + <modal-copy-button + :title="$options.modalInfo.copyToClipboardText" + :text="$options.modalInfo.copyString" + :modal-id="modalId" + css-classes="border-0" + /> + </div> + </div> + <p> + <gl-sprintf :message="$options.instructionText.step3"> + <template #step="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #link="{ content }"> + <gl-link href="blob/master/.gitlab-ci.yml" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 035b276bc3b..bc35a07fe4a 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,13 +1,12 @@ <script> -import { GlButton, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; import { formatTime } from '~/lib/utils/datetime_utility'; import eventHub from '../event_hub'; -import tooltip from '../../vue_shared/directives/tooltip'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlButton, @@ -70,13 +69,14 @@ export default { <template> <div class="btn-group" role="group"> <gl-button - v-tooltip + v-gl-tooltip :title="title" :aria-label="title" :disabled="isLoading" class="dropdown dropdown-new js-environment-actions-dropdown" data-container="body" data-toggle="dropdown" + data-testid="environment-actions-button" > <span> <gl-icon name="play" /> diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue index 55aaa6d57bd..039b40a3596 100644 --- a/app/assets/javascripts/environments/components/environment_delete.vue +++ b/app/assets/javascripts/environments/components/environment_delete.vue @@ -4,7 +4,6 @@ * Used in the environments table and the environment detail view. */ -import $ from 'jquery'; import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; @@ -42,7 +41,7 @@ export default { }, methods: { onClick() { - $(this.$el).tooltip('dispose'); + this.$root.$emit('bv::hide::tooltip', this.$options.deleteEnvironmentTooltipId); eventHub.$emit('requestDeleteEnvironment', this.environment); }, onDeleteEnvironment(environment) { @@ -51,15 +50,16 @@ export default { } }, }, + deleteEnvironmentTooltipId: 'delete-environment-button-tooltip', }; </script> <template> <loading-button - v-gl-tooltip + v-gl-tooltip="{ id: $options.deleteEnvironmentTooltipId }" :loading="isLoading" :title="title" :aria-label="title" - container-class="btn btn-danger d-none d-sm-none d-md-block" + container-class="btn btn-danger d-none d-md-block" data-toggle="modal" data-target="#delete-environment-modal" @click="onClick" diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 8850ed19a4b..48e81b168ec 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -570,7 +570,7 @@ export default { </div> <div - class="table-section deployment-column d-none d-sm-none d-md-block" + class="table-section deployment-column d-none d-md-block" :class="tableData.deploy.spacing" role="gridcell" > @@ -594,11 +594,7 @@ export default { </div> </div> - <div - class="table-section d-none d-sm-none d-md-block" - :class="tableData.build.spacing" - role="gridcell" - > + <div class="table-section d-none d-md-block" :class="tableData.build.spacing" role="gridcell"> <a v-if="shouldRenderBuildName" :href="buildPath" class="build-link cgray"> <tooltip-on-truncate :title="buildName" diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue index c63d54d586d..ff74f81c98e 100644 --- a/app/assets/javascripts/environments/components/environment_stop.vue +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -4,7 +4,6 @@ * Used in environments table. */ -import $ from 'jquery'; import { GlTooltipDirective, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; @@ -40,7 +39,7 @@ export default { }, methods: { onClick() { - $(this.$el).tooltip('dispose'); + this.$root.$emit('bv::hide::tooltip', this.$options.stopEnvironmentTooltipId); eventHub.$emit('requestStopEnvironment', this.environment); }, onStopEnvironment(environment) { @@ -49,11 +48,12 @@ export default { } }, }, + stopEnvironmentTooltipId: 'stop-environment-button-tooltip', }; </script> <template> <gl-button - v-gl-tooltip + v-gl-tooltip="{ id: $options.stopEnvironmentTooltipId }" :loading="isLoading" :title="title" :aria-label="title" diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue index b5a7be90204..4750b8ef01b 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.vue +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -39,7 +39,7 @@ export default { :aria-label="title" :href="terminalPath" :class="{ disabled: disabled }" - class="btn terminal-button d-none d-sm-none d-md-block text-secondary" + class="btn terminal-button d-none d-md-block text-secondary" > <gl-icon name="terminal" /> </a> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index f0e74d96f09..9bafc7ed153 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -1,28 +1,39 @@ <script> -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlBadge, GlButton, GlModalDirective, GlTab, GlTabs } from '@gitlab/ui'; import { deprecatedCreateFlash as Flash } from '~/flash'; import { s__ } from '~/locale'; import emptyState from './empty_state.vue'; import eventHub from '../event_hub'; import environmentsMixin from '../mixins/environments_mixin'; import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin'; -import EnableReviewAppButton from './enable_review_app_button.vue'; +import EnableReviewAppModal from './enable_review_app_modal.vue'; import StopEnvironmentModal from './stop_environment_modal.vue'; import DeleteEnvironmentModal from './delete_environment_modal.vue'; import ConfirmRollbackModal from './confirm_rollback_modal.vue'; export default { + i18n: { + newEnvironmentButtonLabel: s__('Environments|New environment'), + reviewAppButtonLabel: s__('Environments|Enable review app'), + }, + modal: { + id: 'enable-review-app-info', + }, components: { ConfirmRollbackModal, emptyState, - EnableReviewAppButton, - GlDeprecatedButton, + EnableReviewAppModal, + GlBadge, + GlButton, + GlTab, + GlTabs, StopEnvironmentModal, DeleteEnvironmentModal, }, - + directives: { + 'gl-modal': GlModalDirective, + }, mixins: [CIPaginationMixin, environmentsMixin], - props: { endpoint: { type: String, @@ -124,43 +135,108 @@ export default { }; </script> <template> - <div> + <div class="environments-section"> <stop-environment-modal :environment="environmentInStopModal" /> <delete-environment-modal :environment="environmentInDeleteModal" /> <confirm-rollback-modal :environment="environmentInRollbackModal" /> - <div class="top-area"> - <tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" /> - - <div class="nav-controls"> - <enable-review-app-button v-if="state.reviewAppDetails.can_setup_review_app" class="mr-2" /> - <gl-deprecated-button - v-if="canCreateEnvironment && !isLoading" + <div class="gl-w-full"> + <div + class=" + gl-display-flex + gl-flex-direction-column + gl-mt-3 + gl-display-md-none!" + > + <gl-button + v-if="state.reviewAppDetails.can_setup_review_app" + v-gl-modal="$options.modal.id" + data-testid="enable-review-app" + variant="info" + category="secondary" + type="button" + class="gl-mb-3 gl-flex-fill-1" + > + {{ $options.i18n.reviewAppButtonLabel }} + </gl-button> + <gl-button + v-if="canCreateEnvironment" :href="newEnvironmentPath" + data-testid="new-environment" category="primary" variant="success" > - {{ s__('Environments|New environment') }} - </gl-deprecated-button> + {{ $options.i18n.newEnvironmentButtonLabel }} + </gl-button> </div> + <gl-tabs content-class="gl-display-none"> + <gl-tab + v-for="(tab, idx) in tabs" + :key="idx" + :title-item-class="`js-environments-tab-${tab.scope}`" + @click="onChangeTab(tab.scope)" + > + <template #title> + <span>{{ tab.name }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge> + </template> + </gl-tab> + <template #tabs-end> + <div + class=" + gl-display-none + gl-display-md-flex + gl-lg-align-items-center + gl-lg-flex-direction-row + gl-lg-flex-fill-1 + gl-lg-justify-content-end + gl-lg-mt-0" + > + <gl-button + v-if="state.reviewAppDetails.can_setup_review_app" + v-gl-modal="$options.modal.id" + data-testid="enable-review-app" + variant="info" + category="secondary" + type="button" + class="gl-mb-3 gl-lg-mr-3 gl-lg-mb-0" + > + {{ $options.i18n.reviewAppButtonLabel }} + </gl-button> + <gl-button + v-if="canCreateEnvironment" + :href="newEnvironmentPath" + data-testid="new-environment" + category="primary" + variant="success" + > + {{ $options.i18n.newEnvironmentButtonLabel }} + </gl-button> + </div> + </template> + </gl-tabs> + <container + :is-loading="isLoading" + :environments="state.environments" + :pagination="state.paginationInformation" + :can-read-environment="canReadEnvironment" + :canary-deployment-feature-id="canaryDeploymentFeatureId" + :show-canary-deployment-callout="showCanaryDeploymentCallout" + :user-callouts-path="userCalloutsPath" + :lock-promotion-svg-path="lockPromotionSvgPath" + :help-canary-deployments-path="helpCanaryDeploymentsPath" + :deploy-boards-help-path="deployBoardsHelpPath" + @onChangePage="onChangePage" + > + <template v-if="!isLoading && state.environments.length === 0" #emptyState> + <empty-state :help-path="helpPagePath" /> + </template> + </container> + <enable-review-app-modal + v-if="state.reviewAppDetails.can_setup_review_app" + :modal-id="$options.modal.id" + data-testid="enable-review-app-modal" + /> </div> - - <container - :is-loading="isLoading" - :environments="state.environments" - :pagination="state.paginationInformation" - :can-read-environment="canReadEnvironment" - :canary-deployment-feature-id="canaryDeploymentFeatureId" - :show-canary-deployment-callout="showCanaryDeploymentCallout" - :user-callouts-path="userCalloutsPath" - :lock-promotion-svg-path="lockPromotionSvgPath" - :help-canary-deployments-path="helpCanaryDeploymentsPath" - :deploy-boards-help-path="deployBoardsHelpPath" - @onChangePage="onChangePage" - > - <template v-if="!isLoading && state.environments.length === 0" #emptyState> - <empty-state :help-path="helpPagePath" /> - </template> - </container> </div> </template> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index c06ab265915..c1b3eabec16 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -184,7 +184,6 @@ export default { :deploy-boards-help-path="deployBoardsHelpPath" :is-loading="model.isLoadingDeployBoard" :is-empty="model.isEmptyDeployBoard" - :has-legacy-app-label="model.hasLegacyAppLabel" :logs-path="model.logs_path" /> </div> diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue index 88612376b6e..f0dafe0620e 100644 --- a/app/assets/javascripts/environments/components/stop_environment_modal.vue +++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue @@ -1,8 +1,7 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings, vue/no-v-html */ -import { GlTooltipDirective } from '@gitlab/ui'; +/* eslint-disable @gitlab/vue-require-i18n-strings */ +import { GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; -import { s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; export default { @@ -11,6 +10,7 @@ export default { components: { GlModal: DeprecatedModal2, + GlSprintf, }, directives: { @@ -24,27 +24,6 @@ export default { }, }, - computed: { - noStopActionMessage() { - return sprintf( - s__( - `Environments|Note that this action will stop the environment, - but it will %{emphasisStart}not%{emphasisEnd} have an effect on any existing deployment - due to no “stop environment action” being defined - in the %{ciConfigLinkStart}.gitlab-ci.yml%{ciConfigLinkEnd} file.`, - ), - { - emphasisStart: '<strong>', - emphasisEnd: '</strong>', - ciConfigLinkStart: - '<a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer">', - ciConfigLinkEnd: '</a>', - }, - false, - ); - }, - }, - methods: { onSubmit() { eventHub.$emit('stopEnvironment', this.environment); @@ -72,9 +51,27 @@ export default { <p>{{ s__('Environments|Are you sure you want to stop this environment?') }}</p> <div v-if="!environment.has_stop_action" class="warning_message"> - <p v-html="noStopActionMessage"></p> + <p> + <gl-sprintf + :message=" + s__(`Environments|Note that this action will stop the environment, + but it will %{emphasisStart}not%{emphasisEnd} have an effect on any existing deployment + due to no “stop environment action” being defined + in the %{ciConfigLinkStart}.gitlab-ci.yml%{ciConfigLinkEnd} file.`) + " + > + <template #emphasis="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #ciConfigLink="{ content }"> + <a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer"> + {{ content }}</a + > + </template> + </gl-sprintf> + </p> <a - href="https://docs.gitlab.com/ee/ci/environments.html#stopping-an-environment" + href="https://docs.gitlab.com/ee/ci/environments/#stopping-an-environment" target="_blank" rel="noopener noreferrer" >{{ s__('Environments|Learn more about stopping environments') }}</a diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index 16d25615779..25f5483c58b 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -1,4 +1,5 @@ <script> +import { GlBadge, GlTab, GlTabs } from '@gitlab/ui'; import environmentsMixin from '../mixins/environments_mixin'; import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; import StopEnvironmentModal from '../components/stop_environment_modal.vue'; @@ -6,8 +7,11 @@ import DeleteEnvironmentModal from '../components/delete_environment_modal.vue'; export default { components: { - StopEnvironmentModal, DeleteEnvironmentModal, + GlBadge, + GlTab, + GlTabs, + StopEnvironmentModal, }, mixins: [environmentsMixin, CIPaginationMixin], @@ -68,14 +72,26 @@ export default { <stop-environment-modal :environment="environmentInStopModal" /> <delete-environment-modal :environment="environmentInDeleteModal" /> - <h4 class="js-folder-name environments-folder-name"> + <h4 class="gl-font-weight-normal" data-testid="folder-name"> {{ s__('Environments|Environments') }} / <b>{{ folderName }}</b> </h4> - <div class="top-area"> - <tabs v-if="!isLoading" :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" /> - </div> + <gl-tabs v-if="!isLoading" scope="environments" content-class="gl-display-none"> + <gl-tab + v-for="(tab, i) in tabs" + :key="`${tab.name}-${i}`" + :active="tab.isActive" + :title-item-class="tab.isActive ? 'gl-outline-none' : ''" + :title-link-attributes="{ 'data-testid': `environments-tab-${tab.scope}` }" + @click="onChangeTab(tab.scope)" + > + <template #title> + <span>{{ tab.name }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge> + </template> + </gl-tab> + </gl-tabs> <container :is-loading="isLoading" diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index a4938fe13ed..cd4bb476b6e 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -94,7 +94,9 @@ export default { <clipboard-button :title="__('Copy file path')" :text="filePath" - css-class="btn-default btn-transparent btn-clipboard position-static" + category="tertiary" + size="small" + css-class="gl-mr-1" /> <gl-sprintf v-if="errorFn" :message="__('%{spanStart}in%{spanEnd} %{errorFn}')"> diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue index db90ac1c740..786abc8ce49 100644 --- a/app/assets/javascripts/error_tracking_settings/components/app.vue +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -92,15 +92,13 @@ export default { @select-project="updateSelectedProject" /> </div> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button - :disabled="settingsLoading" - class="js-error-tracking-button" - variant="success" - @click="handleSubmit" - > - {{ __('Save changes') }} - </gl-button> - </div> + <gl-button + :disabled="settingsLoading" + class="js-error-tracking-button" + variant="success" + @click="handleSubmit" + > + {{ __('Save changes') }} + </gl-button> </div> </template> diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue index f1fb1a44758..b1b699d2e2a 100644 --- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue +++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue @@ -1,10 +1,9 @@ <script> import { mapActions, mapState } from 'vuex'; -import { GlFormInput, GlIcon } from '@gitlab/ui'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { GlFormInput, GlIcon, GlButton } from '@gitlab/ui'; export default { - components: { GlFormInput, GlIcon, LoadingButton }, + components: { GlFormInput, GlIcon, GlButton }, computed: { ...mapState(['apiHost', 'connectError', 'connectSuccessful', 'isLoadingProjects', 'token']), tokenInputState() { @@ -57,12 +56,16 @@ export default { /> </div> <div class="col-4 col-md-3 gl-pl-0"> - <loading-button + <gl-button class="js-error-tracking-connect gl-ml-2 d-inline-flex" - :label="isLoadingProjects ? __('Connecting') : __('Connect')" + category="secondary" + variant="default" :loading="isLoadingProjects" @click="fetchProjects" - /> + > + {{ isLoadingProjects ? __('Connecting') : __('Connect') }} + </gl-button> + <gl-icon v-show="connectSuccessful" class="js-error-tracking-connect-success gl-ml-2 text-success align-middle" diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue new file mode 100644 index 00000000000..686399843dd --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue @@ -0,0 +1,258 @@ +<script> +import { + GlFormGroup, + GlFormInput, + GlModal, + GlTooltipDirective, + GlLoadingIcon, + GlSprintf, + GlLink, + GlIcon, +} from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import Callout from '~/vue_shared/components/callout.vue'; + +export default { + components: { + GlFormGroup, + GlFormInput, + GlModal, + ModalCopyButton, + GlIcon, + Callout, + GlLoadingIcon, + GlSprintf, + GlLink, + }, + + directives: { + GlTooltip: GlTooltipDirective, + }, + + props: { + instanceId: { + type: String, + required: true, + }, + modalId: { + type: String, + required: false, + default: 'configure-feature-flags', + }, + isRotating: { + type: Boolean, + required: true, + }, + hasRotateError: { + type: Boolean, + required: true, + }, + canUserRotateToken: { + type: Boolean, + required: true, + }, + }, + inject: [ + 'projectName', + 'featureFlagsHelpPagePath', + 'unleashApiUrl', + 'featureFlagsClientExampleHelpPagePath', + 'featureFlagsClientLibrariesHelpPagePath', + ], + translations: { + cancelActionLabel: __('Close'), + modalTitle: s__('FeatureFlags|Configure feature flags'), + apiUrlLabelText: s__('FeatureFlags|API URL'), + apiUrlCopyText: __('Copy URL'), + instanceIdLabelText: s__('FeatureFlags|Instance ID'), + instanceIdCopyText: __('Copy ID'), + instanceIdRegenerateError: __('Unable to generate new instance ID'), + instanceIdRegenerateText: __( + 'Regenerating the instance ID can break integration depending on the client you are using.', + ), + instanceIdRegenerateActionLabel: __('Regenerate instance ID'), + }, + data() { + return { + enteredProjectName: '', + }; + }, + computed: { + cancelActionProps() { + return { + text: this.$options.translations.cancelActionLabel, + }; + }, + canRegenerateInstanceId() { + return this.canUserRotateToken && this.enteredProjectName === this.projectName; + }, + regenerateInstanceIdActionProps() { + return this.canUserRotateToken + ? { + text: this.$options.translations.instanceIdRegenerateActionLabel, + attributes: [ + { + category: 'secondary', + disabled: !this.canRegenerateInstanceId, + loading: this.isRotating, + variant: 'danger', + }, + ], + } + : null; + }, + }, + + methods: { + clearState() { + this.enteredProjectName = ''; + }, + rotateToken() { + this.$emit('token'); + this.clearState(); + }, + }, +}; +</script> +<template> + <gl-modal + :modal-id="modalId" + :action-cancel="cancelActionProps" + :action-primary="regenerateInstanceIdActionProps" + @canceled="clearState" + @hide="clearState" + @primary.prevent="rotateToken" + > + <template #modal-title> + {{ $options.translations.modalTitle }} + </template> + <p> + <gl-sprintf + :message=" + s__( + 'FeatureFlags|Install a %{docsLinkAnchoredStart}compatible client library%{docsLinkAnchoredEnd} and specify the API URL, application name, and instance ID during the configuration setup. %{docsLinkStart}More Information%{docsLinkEnd}', + ) + " + > + <template #docsLinkAnchored="{ content }"> + <gl-link + :href="featureFlagsClientLibrariesHelpPagePath" + target="_blank" + data-testid="help-client-link" + > + {{ content }} + </gl-link> + </template> + <template #docsLink="{ content }"> + <gl-link :href="featureFlagsHelpPagePath" target="_blank" data-testid="help-link">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </p> + + <callout category="warning"> + <gl-sprintf + :message=" + s__( + 'FeatureFlags|Set the Unleash client application name to the name of the environment your application runs in. This value is used to match environment scopes. See the %{linkStart}example client configuration%{linkEnd}.', + ) + " + > + <template #link="{ content }"> + <gl-link :href="featureFlagsClientExampleHelpPagePath" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </callout> + <div class="form-group"> + <label for="api_url" class="label-bold">{{ $options.translations.apiUrlLabelText }}</label> + <div class="input-group"> + <input + id="api_url" + :value="unleashApiUrl" + readonly + class="form-control" + type="text" + name="api_url" + /> + <span class="input-group-append"> + <modal-copy-button + :text="unleashApiUrl" + :title="$options.translations.apiUrlCopyText" + :modal-id="modalId" + class="input-group-text" + /> + </span> + </div> + </div> + <div class="form-group"> + <label for="instance_id" class="label-bold">{{ + $options.translations.instanceIdLabelText + }}</label> + <div class="input-group"> + <input + id="instance_id" + :value="instanceId" + class="form-control" + type="text" + name="instance_id" + readonly + :disabled="isRotating" + /> + + <gl-loading-icon + v-if="isRotating" + class="position-absolute align-self-center instance-id-loading-icon" + /> + + <div class="input-group-append"> + <modal-copy-button + :text="instanceId" + :title="$options.translations.instanceIdCopyText" + :modal-id="modalId" + :disabled="isRotating" + class="input-group-text" + /> + </div> + </div> + </div> + <div + v-if="hasRotateError" + class="text-danger d-flex align-items-center font-weight-normal mb-2" + > + <gl-icon name="warning" class="mr-1" /> + <span>{{ $options.translations.instanceIdRegenerateError }}</span> + </div> + <callout + v-if="canUserRotateToken" + category="danger" + :message="$options.translations.instanceIdRegenerateText" + /> + <p v-if="canUserRotateToken" data-testid="prevent-accident-text"> + <gl-sprintf + :message=" + s__( + 'FeatureFlags|To prevent accidental actions we ask you to confirm your intention. Please type %{projectName} to proceed or close this modal to cancel.', + ) + " + > + <template #projectName> + <span class="gl-font-weight-bold gl-text-red-500">{{ projectName }}</span> + </template> + </gl-sprintf> + </p> + <gl-form-group> + <gl-form-input + v-if="canUserRotateToken" + id="project_name_verification" + v-model="enteredProjectName" + name="project_name" + type="text" + :disabled="isRotating" + /> + </gl-form-group> + </gl-modal> +</template> diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue new file mode 100644 index 00000000000..26b18f9bf5a --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue @@ -0,0 +1,144 @@ +<script> +import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import axios from '~/lib/utils/axios_utils'; +import { sprintf, s__ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { LEGACY_FLAG, NEW_FLAG_ALERT } from '../constants'; +import FeatureFlagForm from './form.vue'; + +export default { + components: { + GlAlert, + GlLoadingIcon, + GlToggle, + FeatureFlagForm, + }, + mixins: [glFeatureFlagMixin()], + inject: { + showUserCallout: {}, + userCalloutId: { + default: '', + }, + userCalloutsPath: { + default: '', + }, + }, + data() { + return { + userShouldSeeNewFlagAlert: this.showUserCallout, + }; + }, + translations: { + legacyFlagAlert: s__( + 'FeatureFlags|GitLab is moving to a new way of managing feature flags, and in 13.4, this feature flag will become read-only. Please create a new feature flag.', + ), + legacyReadOnlyFlagAlert: s__( + 'FeatureFlags|GitLab is moving to a new way of managing feature flags. This feature flag is read-only, and it will be removed in 14.0. Please create a new feature flag.', + ), + newFlagAlert: NEW_FLAG_ALERT, + }, + computed: { + ...mapState([ + 'path', + 'error', + 'name', + 'description', + 'scopes', + 'strategies', + 'isLoading', + 'hasError', + 'iid', + 'active', + 'version', + ]), + title() { + return this.iid + ? `^${this.iid} ${this.name}` + : sprintf(s__('Edit %{name}'), { name: this.name }); + }, + deprecated() { + return this.hasNewVersionFlags && this.version === LEGACY_FLAG; + }, + deprecatedAndEditable() { + return this.deprecated && !this.hasLegacyReadOnlyFlags; + }, + deprecatedAndReadOnly() { + return this.deprecated && this.hasLegacyReadOnlyFlags; + }, + hasNewVersionFlags() { + return this.glFeatures.featureFlagsNewVersion; + }, + hasLegacyReadOnlyFlags() { + return ( + this.glFeatures.featureFlagsLegacyReadOnly && + !this.glFeatures.featureFlagsLegacyReadOnlyOverride + ); + }, + shouldShowNewFlagAlert() { + return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert; + }, + }, + created() { + return this.fetchFeatureFlag(); + }, + methods: { + ...mapActions(['updateFeatureFlag', 'fetchFeatureFlag', 'toggleActive']), + dismissNewVersionFlagAlert() { + this.userShouldSeeNewFlagAlert = false; + axios.post(this.userCalloutsPath, { + feature_name: this.userCalloutId, + }); + }, + }, +}; +</script> +<template> + <div> + <gl-alert + v-if="shouldShowNewFlagAlert" + variant="warning" + class="gl-my-5" + @dismiss="dismissNewVersionFlagAlert" + > + {{ $options.translations.newFlagAlert }} + </gl-alert> + <gl-loading-icon v-if="isLoading" /> + + <template v-else-if="!isLoading && !hasError"> + <gl-alert v-if="deprecatedAndEditable" variant="warning" :dismissible="false" class="gl-my-5"> + {{ $options.translations.legacyFlagAlert }} + </gl-alert> + <gl-alert v-if="deprecatedAndReadOnly" variant="warning" :dismissible="false" class="gl-my-5"> + {{ $options.translations.legacyReadOnlyFlagAlert }} + </gl-alert> + <div class="gl-display-flex gl-align-items-center gl-mb-4 gl-mt-4"> + <gl-toggle + :value="active" + data-testid="feature-flag-status-toggle" + data-track-event="click_button" + data-track-label="feature_flag_toggle" + class="gl-mr-4" + @change="toggleActive" + /> + <h3 class="page-title gl-m-0">{{ title }}</h3> + </div> + + <div v-if="error.length" class="alert alert-danger"> + <p v-for="(message, index) in error" :key="index" class="gl-mb-0">{{ message }}</p> + </div> + + <feature-flag-form + :name="name" + :description="description" + :scopes="scopes" + :strategies="strategies" + :cancel-path="path" + :submit-text="__('Save changes')" + :active="active" + :version="version" + @handleSubmit="data => updateFeatureFlag(data)" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue new file mode 100644 index 00000000000..3caf536b6a2 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue @@ -0,0 +1,181 @@ +<script> +import { debounce } from 'lodash'; +import { GlDeprecatedButton, GlSearchBoxByType } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; + +/** + * Creates a searchable input for environments. + * + * When given a value, it will render it as selected value + * Otherwise it will render a placeholder for the search input. + * It will fetch the available environments on focus. + * + * When the user types, it will trigger an event to allow + * for API queries outside of the component. + * + * When results are returned, it renders a selectable + * list with the suggestions + * + * When no results are returned, it will render a + * button with a `Create` label. When clicked, it will + * emit an event to allow for the creation of a new + * record. + * + */ + +export default { + name: 'EnvironmentsSearchableInput', + components: { + GlDeprecatedButton, + GlSearchBoxByType, + }, + props: { + value: { + type: String, + required: false, + default: '', + }, + placeholder: { + type: String, + required: false, + default: __('Search an environment spec'), + }, + createButtonLabel: { + type: String, + required: false, + default: __('Create'), + }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + }, + inject: ['environmentsEndpoint'], + data() { + return { + environmentSearch: this.value, + results: [], + showSuggestions: false, + isLoading: false, + }; + }, + computed: { + /** + * Creates a label with the value of the filter + * @returns {String} + */ + composedCreateButtonLabel() { + return `${this.createButtonLabel} ${this.environmentSearch}`; + }, + shouldRenderCreateButton() { + return !this.isLoading && !this.results.length; + }, + }, + methods: { + fetchEnvironments: debounce(function debouncedFetchEnvironments() { + this.isLoading = true; + this.openSuggestions(); + axios + .get(this.environmentsEndpoint, { params: { query: this.environmentSearch } }) + .then(({ data }) => { + this.results = data || []; + this.isLoading = false; + }) + .catch(() => { + this.isLoading = false; + this.closeSuggestions(); + createFlash(__('Something went wrong on our end. Please try again.')); + }); + }, 250), + /** + * Opens the list of suggestions + */ + openSuggestions() { + this.showSuggestions = true; + }, + /** + * Closes the list of suggestions and cleans the results + */ + closeSuggestions() { + this.showSuggestions = false; + this.environmentSearch = ''; + }, + /** + * On click, it will: + * 1. clear the input value + * 2. close the list of suggestions + * 3. emit an event + */ + clearInput() { + this.closeSuggestions(); + this.$emit('clearInput'); + }, + /** + * When the user selects a value from the list of suggestions + * + * It emits an event with the selected value + * Clears the filter + * and closes the list of suggestions + * + * @param {String} selected + */ + selectEnvironment(selected) { + this.$emit('selectEnvironment', selected); + this.results = []; + this.closeSuggestions(); + }, + + /** + * When the user clicks the create button + * it emits an event with the filter value + */ + createClicked() { + this.$emit('createClicked', this.environmentSearch); + this.closeSuggestions(); + }, + }, +}; +</script> +<template> + <div> + <div class="dropdown position-relative"> + <gl-search-box-by-type + v-model.trim="environmentSearch" + class="js-env-search" + :aria-label="placeholder" + :placeholder="placeholder" + :disabled="disabled" + :is-loading="isLoading" + @focus="fetchEnvironments" + @keyup="fetchEnvironments" + /> + <div + v-if="showSuggestions" + class="dropdown-menu d-block dropdown-menu-selectable dropdown-menu-full-width" + > + <div class="dropdown-content"> + <ul v-if="results.length"> + <li v-for="(result, i) in results" :key="i"> + <gl-deprecated-button class="btn-transparent" @click="selectEnvironment(result)">{{ + result + }}</gl-deprecated-button> + </li> + </ul> + <div v-else-if="!results.length" class="text-secondary gl-p-3"> + {{ __('No matching results') }} + </div> + <div v-if="shouldRenderCreateButton" class="dropdown-footer"> + <gl-deprecated-button + class="js-create-button btn-blank dropdown-item" + @click="createClicked" + >{{ composedCreateButtonLabel }}</gl-deprecated-button + > + </div> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue new file mode 100644 index 00000000000..eb7046a3d9b --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -0,0 +1,326 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { isEmpty } from 'lodash'; +import { GlAlert, GlButton, GlModalDirective, GlSprintf, GlTabs } from '@gitlab/ui'; + +import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants'; +import FeatureFlagsTab from './feature_flags_tab.vue'; +import FeatureFlagsTable from './feature_flags_table.vue'; +import UserListsTable from './user_lists_table.vue'; +import { s__ } from '~/locale'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import { + buildUrlWithCurrentLocation, + getParameterByName, + historyPushState, +} from '~/lib/utils/common_utils'; + +import ConfigureFeatureFlagsModal from './configure_feature_flags_modal.vue'; + +const SCOPES = { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE }; + +export default { + components: { + ConfigureFeatureFlagsModal, + FeatureFlagsTab, + FeatureFlagsTable, + GlAlert, + GlButton, + GlSprintf, + GlTabs, + TablePagination, + UserListsTable, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: { + newUserListPath: { default: '' }, + newFeatureFlagPath: { default: '' }, + canUserConfigure: { required: true }, + featureFlagsLimitExceeded: { required: true }, + }, + data() { + const scope = getParameterByName('scope') || SCOPES.FEATURE_FLAG_SCOPE; + return { + scope, + page: getParameterByName('page') || '1', + isUserListAlertDismissed: false, + shouldShowFeatureFlagsLimitWarning: this.featureFlagsLimitExceeded, + selectedTab: Object.values(SCOPES).indexOf(scope), + }; + }, + computed: { + ...mapState([ + FEATURE_FLAG_SCOPE, + USER_LIST_SCOPE, + 'alerts', + 'count', + 'pageInfo', + 'isLoading', + 'hasError', + 'options', + 'instanceId', + 'isRotating', + 'hasRotateError', + ]), + topAreaBaseClasses() { + return ['gl-display-flex', 'gl-flex-direction-column']; + }, + canUserRotateToken() { + return this.rotateInstanceIdPath !== ''; + }, + currentlyDisplayedData() { + return this.dataForScope(this.scope); + }, + shouldRenderPagination() { + return ( + !this.isLoading && + !this.hasError && + this.currentlyDisplayedData.length > 0 && + this.pageInfo[this.scope].total > this.pageInfo[this.scope].perPage + ); + }, + shouldShowEmptyState() { + return !this.isLoading && !this.hasError && this.currentlyDisplayedData.length === 0; + }, + shouldRenderErrorState() { + return this.hasError && !this.isLoading; + }, + shouldRenderFeatureFlags() { + return this.shouldRenderTable(SCOPES.FEATURE_FLAG_SCOPE); + }, + shouldRenderUserLists() { + return this.shouldRenderTable(SCOPES.USER_LIST_SCOPE); + }, + hasNewPath() { + return !isEmpty(this.newFeatureFlagPath); + }, + emptyStateTitle() { + return s__('FeatureFlags|Get started with feature flags'); + }, + }, + created() { + this.setFeatureFlagsOptions({ scope: this.scope, page: this.page }); + this.fetchFeatureFlags(); + this.fetchUserLists(); + }, + methods: { + ...mapActions([ + 'setFeatureFlagsOptions', + 'fetchFeatureFlags', + 'fetchUserLists', + 'rotateInstanceId', + 'toggleFeatureFlag', + 'deleteUserList', + 'clearAlert', + ]), + onChangeTab(scope) { + this.scope = scope; + this.updateFeatureFlagOptions({ + scope, + page: '1', + }); + }, + onFeatureFlagsTab() { + this.onChangeTab(SCOPES.FEATURE_FLAG_SCOPE); + }, + onUserListsTab() { + this.onChangeTab(SCOPES.USER_LIST_SCOPE); + }, + onChangePage(page) { + this.updateFeatureFlagOptions({ + scope: this.scope, + /* URLS parameters are strings, we need to parse to match types */ + page: Number(page).toString(), + }); + }, + updateFeatureFlagOptions(parameters) { + const queryString = Object.keys(parameters) + .map(parameter => { + const value = parameters[parameter]; + return `${parameter}=${encodeURIComponent(value)}`; + }) + .join('&'); + + historyPushState(buildUrlWithCurrentLocation(`?${queryString}`)); + this.setFeatureFlagsOptions(parameters); + if (this.scope === SCOPES.FEATURE_FLAG_SCOPE) { + this.fetchFeatureFlags(); + } else { + this.fetchUserLists(); + } + }, + shouldRenderTable(scope) { + return ( + !this.isLoading && + this.dataForScope(scope).length > 0 && + !this.hasError && + this.scope === scope + ); + }, + dataForScope(scope) { + return this[scope]; + }, + onDismissFeatureFlagsLimitWarning() { + this.shouldShowFeatureFlagsLimitWarning = false; + }, + onNewFeatureFlagCLick() { + if (this.featureFlagsLimitExceeded) { + this.shouldShowFeatureFlagsLimitWarning = true; + } + }, + }, +}; +</script> +<template> + <div> + <gl-alert + v-if="shouldShowFeatureFlagsLimitWarning" + variant="warning" + @dismiss="onDismissFeatureFlagsLimitWarning" + > + <gl-sprintf + :message=" + s__( + 'FeatureFlags|Feature flags limit reached (%{featureFlagsLimit}). Delete one or more feature flags before adding new ones.', + ) + " + > + <template #featureFlagsLimit> + <span>{{ featureFlagsLimit }}</span> + </template> + </gl-sprintf> + </gl-alert> + <configure-feature-flags-modal + v-if="canUserConfigure" + :instance-id="instanceId" + :is-rotating="isRotating" + :has-rotate-error="hasRotateError" + :can-user-rotate-token="canUserRotateToken" + modal-id="configure-feature-flags" + @token="rotateInstanceId()" + /> + <div :class="topAreaBaseClasses"> + <div class="gl-display-flex gl-flex-direction-column gl-display-md-none!"> + <gl-button + v-if="canUserConfigure" + v-gl-modal="'configure-feature-flags'" + variant="info" + category="secondary" + data-qa-selector="configure_feature_flags_button" + data-testid="ff-configure-button" + class="gl-mb-3" + > + {{ s__('FeatureFlags|Configure') }} + </gl-button> + + <gl-button + v-if="newUserListPath" + :href="newUserListPath" + variant="success" + category="secondary" + class="gl-mb-3" + data-testid="ff-new-list-button" + > + {{ s__('FeatureFlags|New user list') }} + </gl-button> + + <gl-button + v-if="hasNewPath" + :href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath" + variant="success" + data-testid="ff-new-button" + @click="onNewFeatureFlagCLick" + > + {{ s__('FeatureFlags|New feature flag') }} + </gl-button> + </div> + <gl-tabs v-model="selectedTab" class="gl-align-items-center gl-w-full"> + <feature-flags-tab + :title="s__('FeatureFlags|Feature Flags')" + :count="count.featureFlags" + :alerts="alerts" + :is-loading="isLoading" + :loading-label="s__('FeatureFlags|Loading feature flags')" + :error-state="shouldRenderErrorState" + :error-title="s__(`FeatureFlags|There was an error fetching the feature flags.`)" + :empty-state="shouldShowEmptyState" + :empty-title="emptyStateTitle" + data-testid="feature-flags-tab" + @dismissAlert="clearAlert" + @changeTab="onFeatureFlagsTab" + > + <feature-flags-table + v-if="shouldRenderFeatureFlags" + :feature-flags="featureFlags" + @toggle-flag="toggleFeatureFlag" + /> + </feature-flags-tab> + <feature-flags-tab + :title="s__('FeatureFlags|User Lists')" + :count="count.userLists" + :alerts="alerts" + :is-loading="isLoading" + :loading-label="s__('FeatureFlags|Loading user lists')" + :error-state="shouldRenderErrorState" + :error-title="s__(`FeatureFlags|There was an error fetching the user lists.`)" + :empty-state="shouldShowEmptyState" + :empty-title="emptyStateTitle" + data-testid="user-lists-tab" + @dismissAlert="clearAlert" + @changeTab="onUserListsTab" + > + <user-lists-table + v-if="shouldRenderUserLists" + :user-lists="userLists" + @delete="deleteUserList" + /> + </feature-flags-tab> + <template #tabs-end> + <div + class="gl-display-none gl-display-md-flex gl-align-items-center gl-flex-fill-1 gl-justify-content-end" + > + <gl-button + v-if="canUserConfigure" + v-gl-modal="'configure-feature-flags'" + variant="info" + category="secondary" + data-qa-selector="configure_feature_flags_button" + data-testid="ff-configure-button" + class="gl-mb-0 gl-mr-4" + > + {{ s__('FeatureFlags|Configure') }} + </gl-button> + + <gl-button + v-if="newUserListPath" + :href="newUserListPath" + variant="success" + category="secondary" + class="gl-mb-0 gl-mr-4" + data-testid="ff-new-list-button" + > + {{ s__('FeatureFlags|New user list') }} + </gl-button> + + <gl-button + v-if="hasNewPath" + :href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath" + variant="success" + data-testid="ff-new-button" + @click="onNewFeatureFlagCLick" + > + {{ s__('FeatureFlags|New feature flag') }} + </gl-button> + </div> + </template> + </gl-tabs> + </div> + <table-pagination + v-if="shouldRenderPagination" + :change="onChangePage" + :page-info="pageInfo[scope]" + /> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue new file mode 100644 index 00000000000..5c35aa33e14 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue @@ -0,0 +1,108 @@ +<script> +import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab } from '@gitlab/ui'; + +export default { + components: { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab }, + props: { + title: { + required: true, + type: String, + }, + count: { + required: false, + type: Number, + default: null, + }, + alerts: { + required: true, + type: Array, + }, + isLoading: { + required: true, + type: Boolean, + }, + loadingLabel: { + required: true, + type: String, + }, + errorState: { + required: true, + type: Boolean, + }, + errorTitle: { + required: true, + type: String, + }, + emptyState: { + required: true, + type: Boolean, + }, + emptyTitle: { + required: true, + type: String, + }, + }, + inject: ['errorStateSvgPath', 'featureFlagsHelpPagePath'], + computed: { + itemCount() { + return this.count ?? 0; + }, + }, + methods: { + clearAlert(index) { + this.$emit('dismissAlert', index); + }, + onClick(event) { + return this.$emit('changeTab', event); + }, + }, +}; +</script> +<template> + <gl-tab @click="onClick"> + <template #title> + <span data-testid="feature-flags-tab-title">{{ title }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ itemCount }}</gl-badge> + </template> + <template> + <gl-alert + v-for="(message, index) in alerts" + :key="index" + data-testid="serverErrors" + variant="danger" + @dismiss="clearAlert(index)" + > + {{ message }} + </gl-alert> + + <gl-loading-icon v-if="isLoading" :label="loadingLabel" size="md" class="gl-mt-4" /> + + <gl-empty-state + v-else-if="errorState" + :title="errorTitle" + :description="s__(`FeatureFlags|Try again in a few moments or contact your support team.`)" + :svg-path="errorStateSvgPath" + data-testid="error-state" + /> + + <gl-empty-state + v-else-if="emptyState" + :title="emptyTitle" + :svg-path="errorStateSvgPath" + data-testid="empty-state" + > + <template #description> + {{ + s__( + 'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.', + ) + }} + <gl-link :href="featureFlagsHelpPagePath" target="_blank"> + {{ s__('FeatureFlags|More information') }} + </gl-link> + </template> + </gl-empty-state> + <slot> </slot> + </template> + </gl-tab> +</template> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue new file mode 100644 index 00000000000..54d038606f4 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue @@ -0,0 +1,271 @@ +<script> +import { GlBadge, GlButton, GlTooltipDirective, GlModal, GlToggle, GlIcon } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ROLLOUT_STRATEGY_PERCENT_ROLLOUT, NEW_VERSION_FLAG, LEGACY_FLAG } from '../constants'; +import { labelForStrategy } from '../utils'; + +export default { + components: { + GlBadge, + GlButton, + GlIcon, + GlModal, + GlToggle, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [glFeatureFlagMixin()], + props: { + featureFlags: { + type: Array, + required: true, + }, + }, + inject: ['csrfToken'], + data() { + return { + deleteFeatureFlagUrl: null, + deleteFeatureFlagName: null, + }; + }, + translations: { + legacyFlagAlert: s__('FeatureFlags|Flag becomes read only soon'), + legacyFlagReadOnlyAlert: s__('FeatureFlags|Flag is read-only'), + }, + computed: { + permissions() { + return this.glFeatures.featureFlagPermissions; + }, + isNewVersionFlagsEnabled() { + return this.glFeatures.featureFlagsNewVersion; + }, + isLegacyReadOnlyFlagsEnabled() { + return ( + this.glFeatures.featureFlagsLegacyReadOnly && + !this.glFeatures.featureFlagsLegacyReadOnlyOverride + ); + }, + modalTitle() { + return sprintf(s__('FeatureFlags|Delete %{name}?'), { + name: this.deleteFeatureFlagName, + }); + }, + deleteModalMessage() { + return sprintf(s__('FeatureFlags|Feature flag %{name} will be removed. Are you sure?'), { + name: this.deleteFeatureFlagName, + }); + }, + modalId() { + return 'delete-feature-flag'; + }, + legacyFlagToolTipText() { + const { legacyFlagReadOnlyAlert, legacyFlagAlert } = this.$options.translations; + + return this.isLegacyReadOnlyFlagsEnabled ? legacyFlagReadOnlyAlert : legacyFlagAlert; + }, + }, + methods: { + isLegacyFlag(flag) { + return !this.isNewVersionFlagsEnabled || flag.version !== NEW_VERSION_FLAG; + }, + statusToggleDisabled(flag) { + return this.isLegacyReadOnlyFlagsEnabled && flag.version === LEGACY_FLAG; + }, + scopeTooltipText(scope) { + return !scope.active + ? sprintf(s__('FeatureFlags|Inactive flag for %{scope}'), { + scope: scope.environmentScope, + }) + : ''; + }, + badgeText(scope) { + const displayName = + scope.environmentScope === '*' + ? s__('FeatureFlags|* (All environments)') + : scope.environmentScope; + + const displayPercentage = + scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT + ? `: ${scope.rolloutPercentage}%` + : ''; + + return `${displayName}${displayPercentage}`; + }, + badgeVariant(scope) { + return scope.active ? 'info' : 'muted'; + }, + strategyBadgeText(strategy) { + return labelForStrategy(strategy); + }, + featureFlagIidText(featureFlag) { + return featureFlag.iid ? `^${featureFlag.iid}` : ''; + }, + canDeleteFlag(flag) { + return !this.permissions || (flag.scopes || []).every(scope => scope.can_update); + }, + setDeleteModalData(featureFlag) { + this.deleteFeatureFlagUrl = featureFlag.destroy_path; + this.deleteFeatureFlagName = featureFlag.name; + + this.$refs[this.modalId].show(); + }, + onSubmit() { + this.$refs.form.submit(); + }, + toggleFeatureFlag(flag) { + this.$emit('toggle-flag', { + ...flag, + active: !flag.active, + }); + }, + }, +}; +</script> +<template> + <div class="table-holder js-feature-flag-table"> + <div class="gl-responsive-table-row table-row-header" role="row"> + <div class="table-section section-10"> + {{ s__('FeatureFlags|ID') }} + </div> + <div class="table-section section-10" role="columnheader"> + {{ s__('FeatureFlags|Status') }} + </div> + <div class="table-section section-20" role="columnheader"> + {{ s__('FeatureFlags|Feature Flag') }} + </div> + <div class="table-section section-40" role="columnheader"> + {{ s__('FeatureFlags|Environment Specs') }} + </div> + </div> + + <template v-for="featureFlag in featureFlags"> + <div :key="featureFlag.id" class="gl-responsive-table-row" role="row"> + <div class="table-section section-10" role="gridcell"> + <div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|ID') }}</div> + <div class="table-mobile-content js-feature-flag-id"> + {{ featureFlagIidText(featureFlag) }} + </div> + </div> + <div class="table-section section-10" role="gridcell"> + <div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|Status') }}</div> + <div class="table-mobile-content"> + <gl-toggle + v-if="featureFlag.update_path" + :value="featureFlag.active" + :disabled="statusToggleDisabled(featureFlag)" + data-testid="feature-flag-status-toggle" + data-track-event="click_button" + data-track-label="feature_flag_toggle" + @change="toggleFeatureFlag(featureFlag)" + /> + <gl-badge + v-else-if="featureFlag.active" + variant="success" + data-testid="feature-flag-status-badge" + > + {{ s__('FeatureFlags|Active') }} + </gl-badge> + <gl-badge v-else variant="danger">{{ s__('FeatureFlags|Inactive') }}</gl-badge> + </div> + </div> + + <div class="table-section section-20" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Feature Flag') }} + </div> + <div class="table-mobile-content d-flex flex-column js-feature-flag-title"> + <div class="gl-display-flex gl-align-items-center"> + <div class="feature-flag-name text-monospace text-truncate"> + {{ featureFlag.name }} + </div> + <gl-icon + v-if="isLegacyFlag(featureFlag)" + v-gl-tooltip.hover="legacyFlagToolTipText" + class="gl-ml-3" + name="information-o" + /> + </div> + <div class="feature-flag-description text-secondary text-truncate"> + {{ featureFlag.description }} + </div> + </div> + </div> + + <div class="table-section section-40" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Environment Specs') }} + </div> + <div + class="table-mobile-content d-flex flex-wrap justify-content-end justify-content-md-start js-feature-flag-environments" + > + <template v-if="isLegacyFlag(featureFlag)"> + <gl-badge + v-for="scope in featureFlag.scopes" + :key="scope.id" + v-gl-tooltip.hover="scopeTooltipText(scope)" + :variant="badgeVariant(scope)" + :data-qa-selector="`feature-flag-scope-${badgeVariant(scope)}-badge`" + class="gl-mr-3 gl-mt-2" + > + {{ badgeText(scope) }} + </gl-badge> + </template> + <template v-else> + <gl-badge + v-for="strategy in featureFlag.strategies" + :key="strategy.id" + data-testid="strategy-badge" + variant="info" + class="gl-mr-3 gl-mt-2" + > + {{ strategyBadgeText(strategy) }} + </gl-badge> + </template> + </div> + </div> + + <div class="table-section section-20 table-button-footer" role="gridcell"> + <div class="table-action-buttons btn-group"> + <template v-if="featureFlag.edit_path"> + <gl-button + v-gl-tooltip.hover.bottom="__('Edit')" + class="js-feature-flag-edit-button" + icon="pencil" + :href="featureFlag.edit_path" + /> + </template> + <template v-if="featureFlag.destroy_path"> + <gl-button + v-gl-tooltip.hover.bottom="__('Delete')" + class="js-feature-flag-delete-button" + variant="danger" + icon="remove" + :disabled="!canDeleteFlag(featureFlag)" + @click="setDeleteModalData(featureFlag)" + /> + </template> + </div> + </div> + </div> + </template> + + <gl-modal + :ref="modalId" + :title="modalTitle" + :ok-title="s__('FeatureFlags|Delete feature flag')" + :modal-id="modalId" + title-tag="h4" + ok-variant="danger" + category="primary" + @ok="onSubmit" + > + {{ deleteModalMessage }} + <form ref="form" :action="deleteFeatureFlagUrl" method="post" class="js-requires-input"> + <input ref="method" type="hidden" name="_method" value="delete" /> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + </form> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue new file mode 100644 index 00000000000..3c1944d91bd --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/form.vue @@ -0,0 +1,606 @@ +<script> +import Vue from 'vue'; +import { memoize, isString, cloneDeep, isNumber, uniqueId } from 'lodash'; +import { + GlButton, + GlDeprecatedBadge as GlBadge, + GlTooltip, + GlTooltipDirective, + GlFormTextarea, + GlFormCheckbox, + GlSprintf, + GlIcon, +} from '@gitlab/ui'; +import Api from '~/api'; +import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; +import { s__ } from '~/locale'; +import { deprecatedCreateFlash as flash, FLASH_TYPES } from '~/flash'; +import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ToggleButton from '~/vue_shared/components/toggle_button.vue'; +import EnvironmentsDropdown from './environments_dropdown.vue'; +import Strategy from './strategy.vue'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ALL_ENVIRONMENTS_NAME, + INTERNAL_ID_PREFIX, + NEW_VERSION_FLAG, + LEGACY_FLAG, +} from '../constants'; +import { createNewEnvironmentScope } from '../store/helpers'; + +export default { + components: { + GlButton, + GlBadge, + GlFormTextarea, + GlFormCheckbox, + GlTooltip, + GlSprintf, + GlIcon, + ToggleButton, + EnvironmentsDropdown, + Strategy, + RelatedIssuesRoot, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [featureFlagsMixin()], + props: { + active: { + type: Boolean, + required: false, + default: true, + }, + name: { + type: String, + required: false, + default: '', + }, + description: { + type: String, + required: false, + default: '', + }, + scopes: { + type: Array, + required: false, + default: () => [], + }, + cancelPath: { + type: String, + required: true, + }, + submitText: { + type: String, + required: true, + }, + strategies: { + type: Array, + required: false, + default: () => [], + }, + version: { + type: String, + required: false, + default: LEGACY_FLAG, + }, + }, + inject: { + projectId: {}, + featureFlagIssuesEndpoint: { + default: '', + }, + }, + translations: { + allEnvironmentsText: s__('FeatureFlags|* (All Environments)'), + + helpText: s__( + 'FeatureFlags|Feature Flag behavior is built up by creating a set of rules to define the status of target environments. A default wildcard rule %{codeStart}*%{codeEnd} for %{boldStart}All Environments%{boldEnd} is set, and you are able to add as many rules as you need by choosing environment specs below. You can toggle the behavior for each of your rules to set them %{boldStart}Active%{boldEnd} or %{boldStart}Inactive%{boldEnd}.', + ), + + newHelpText: s__( + 'FeatureFlags|Enable features for specific users and environments by configuring feature flag strategies.', + ), + noStrategiesText: s__('FeatureFlags|Feature Flag has no strategies'), + }, + + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + + // Matches numbers 0 through 100 + rolloutPercentageRegex: /^[0-9]$|^[1-9][0-9]$|^100$/, + + data() { + return { + formName: this.name, + formDescription: this.description, + + // operate on a clone to avoid mutating props + formScopes: this.scopes.map(s => ({ ...s })), + formStrategies: cloneDeep(this.strategies), + + newScope: '', + userLists: [], + }; + }, + computed: { + filteredScopes() { + return this.formScopes.filter(scope => !scope.shouldBeDestroyed); + }, + filteredStrategies() { + return this.formStrategies.filter(s => !s.shouldBeDestroyed); + }, + canUpdateFlag() { + return !this.permissionsFlag || (this.formScopes || []).every(scope => scope.canUpdate); + }, + permissionsFlag() { + return this.glFeatures.featureFlagPermissions; + }, + supportsStrategies() { + return this.glFeatures.featureFlagsNewVersion && this.version === NEW_VERSION_FLAG; + }, + showRelatedIssues() { + return this.featureFlagIssuesEndpoint.length > 0; + }, + readOnly() { + return ( + this.glFeatures.featureFlagsNewVersion && + this.glFeatures.featureFlagsLegacyReadOnly && + !this.glFeatures.featureFlagsLegacyReadOnlyOverride && + this.version === LEGACY_FLAG + ); + }, + }, + mounted() { + if (this.supportsStrategies) { + Api.fetchFeatureFlagUserLists(this.projectId) + .then(({ data }) => { + this.userLists = data; + }) + .catch(() => { + flash(s__('FeatureFlags|There was an error retrieving user lists'), FLASH_TYPES.WARNING); + }); + } + }, + methods: { + keyFor(strategy) { + if (strategy.id) { + return strategy.id; + } + + return uniqueId('strategy_'); + }, + + addStrategy() { + this.formStrategies.push({ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] }); + }, + + deleteStrategy(s) { + if (isNumber(s.id)) { + Vue.set(s, 'shouldBeDestroyed', true); + } else { + this.formStrategies = this.formStrategies.filter(strategy => strategy !== s); + } + }, + + isAllEnvironment(name) { + return name === ALL_ENVIRONMENTS_NAME; + }, + + /** + * When the user clicks the remove button we delete the scope + * + * If the scope has an ID, we need to add the `shouldBeDestroyed` flag. + * If the scope does *not* have an ID, we can just remove it. + * + * This flag will be used when submitting the data to the backend + * to determine which records to delete (via a "_destroy" property). + * + * @param {Object} scope + */ + removeScope(scope) { + if (isString(scope.id) && scope.id.startsWith(INTERNAL_ID_PREFIX)) { + this.formScopes = this.formScopes.filter(s => s !== scope); + } else { + Vue.set(scope, 'shouldBeDestroyed', true); + } + }, + + /** + * Creates a new scope and adds it to the list of scopes + * + * @param overrides An object whose properties will + * be used override the default scope options + */ + createNewScope(overrides) { + this.formScopes.push(createNewEnvironmentScope(overrides, this.permissionsFlag)); + this.newScope = ''; + }, + + /** + * When the user clicks the submit button + * it triggers an event with the form data + */ + handleSubmit() { + const flag = { + name: this.formName, + description: this.formDescription, + active: this.active, + version: this.version, + }; + + if (this.version === LEGACY_FLAG) { + flag.scopes = this.formScopes; + } else { + flag.strategies = this.formStrategies; + } + + this.$emit('handleSubmit', flag); + }, + + canUpdateScope(scope) { + return !this.permissionsFlag || scope.canUpdate; + }, + + isRolloutPercentageInvalid: memoize(function isRolloutPercentageInvalid(percentage) { + return !this.$options.rolloutPercentageRegex.test(percentage); + }), + + /** + * Generates a unique ID for the strategy based on the v-for index + * + * @param index The index of the strategy + */ + rolloutStrategyId(index) { + return `rollout-strategy-${index}`; + }, + + /** + * Generates a unique ID for the percentage based on the v-for index + * + * @param index The index of the percentage + */ + rolloutPercentageId(index) { + return `rollout-percentage-${index}`; + }, + rolloutUserId(index) { + return `rollout-user-id-${index}`; + }, + + shouldDisplayIncludeUserIds(scope) { + return ![ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_USER_ID].includes( + scope.rolloutStrategy, + ); + }, + shouldDisplayUserIds(scope) { + return scope.rolloutStrategy === ROLLOUT_STRATEGY_USER_ID || scope.shouldIncludeUserIds; + }, + onStrategyChange(index) { + const scope = this.filteredScopes[index]; + scope.shouldIncludeUserIds = + scope.rolloutUserIds.length > 0 && + scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT; + }, + onFormStrategyChange(strategy, index) { + Object.assign(this.filteredStrategies[index], strategy); + }, + }, +}; +</script> +<template> + <form class="feature-flags-form"> + <fieldset> + <div class="row"> + <div class="form-group col-md-4"> + <label for="feature-flag-name" class="label-bold">{{ s__('FeatureFlags|Name') }} *</label> + <input + id="feature-flag-name" + v-model="formName" + :disabled="!canUpdateFlag" + class="form-control" + /> + </div> + </div> + + <div class="row"> + <div class="form-group col-md-4"> + <label for="feature-flag-description" class="label-bold"> + {{ s__('FeatureFlags|Description') }} + </label> + <textarea + id="feature-flag-description" + v-model="formDescription" + :disabled="!canUpdateFlag" + class="form-control" + rows="4" + ></textarea> + </div> + </div> + + <related-issues-root + v-if="showRelatedIssues" + :endpoint="featureFlagIssuesEndpoint" + :can-admin="true" + :show-categorized-issues="false" + /> + + <template v-if="supportsStrategies"> + <div class="row"> + <div class="col-md-12"> + <h4>{{ s__('FeatureFlags|Strategies') }}</h4> + <div class="flex align-items-baseline justify-content-between"> + <p class="mr-3">{{ $options.translations.newHelpText }}</p> + <gl-button variant="success" category="secondary" @click="addStrategy"> + {{ s__('FeatureFlags|Add strategy') }} + </gl-button> + </div> + </div> + </div> + <div v-if="filteredStrategies.length > 0" data-testid="feature-flag-strategies"> + <strategy + v-for="(strategy, index) in filteredStrategies" + :key="keyFor(strategy)" + :strategy="strategy" + :index="index" + :user-lists="userLists" + @change="onFormStrategyChange($event, index)" + @delete="deleteStrategy(strategy)" + /> + </div> + <div v-else class="flex justify-content-center border-top py-4 w-100"> + <span>{{ $options.translations.noStrategiesText }}</span> + </div> + </template> + + <div v-else class="row"> + <div class="form-group col-md-12"> + <h4>{{ s__('FeatureFlags|Target environments') }}</h4> + <gl-sprintf :message="$options.translations.helpText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + <template #bold="{ content }"> + <b>{{ content }}</b> + </template> + </gl-sprintf> + + <div class="js-scopes-table gl-mt-3"> + <div class="gl-responsive-table-row table-row-header" role="row"> + <div class="table-section section-30" role="columnheader"> + {{ s__('FeatureFlags|Environment Spec') }} + </div> + <div class="table-section section-20 text-center" role="columnheader"> + {{ s__('FeatureFlags|Status') }} + </div> + <div class="table-section section-40" role="columnheader"> + {{ s__('FeatureFlags|Rollout Strategy') }} + </div> + </div> + + <div + v-for="(scope, index) in filteredScopes" + :key="scope.id" + ref="scopeRow" + class="gl-responsive-table-row" + role="row" + > + <div class="table-section section-30" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Environment Spec') }} + </div> + <div + class="table-mobile-content js-feature-flag-status d-flex align-items-center justify-content-start" + > + <p v-if="isAllEnvironment(scope.environmentScope)" class="js-scope-all pl-3"> + {{ $options.translations.allEnvironmentsText }} + </p> + + <environments-dropdown + v-else + class="col-12" + :value="scope.environmentScope" + :disabled="!canUpdateScope(scope) || scope.environmentScope !== ''" + @selectEnvironment="env => (scope.environmentScope = env)" + @createClicked="env => (scope.environmentScope = env)" + @clearInput="env => (scope.environmentScope = '')" + /> + + <gl-badge v-if="permissionsFlag && scope.protected" variant="success"> + {{ s__('FeatureFlags|Protected') }} + </gl-badge> + </div> + </div> + + <div class="table-section section-20 text-center" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Status') }} + </div> + <div class="table-mobile-content js-feature-flag-status"> + <toggle-button + :value="scope.active" + :disabled-input="!active || !canUpdateScope(scope)" + @change="status => (scope.active = status)" + /> + </div> + </div> + + <div class="table-section section-40" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Rollout Strategy') }} + </div> + <div class="table-mobile-content js-rollout-strategy form-inline"> + <label class="sr-only" :for="rolloutStrategyId(index)"> + {{ s__('FeatureFlags|Rollout Strategy') }} + </label> + <div class="select-wrapper col-12 col-md-8 p-0"> + <select + :id="rolloutStrategyId(index)" + v-model="scope.rolloutStrategy" + :disabled="!scope.active" + class="form-control select-control w-100 js-rollout-strategy" + @change="onStrategyChange(index)" + > + <option :value="$options.ROLLOUT_STRATEGY_ALL_USERS"> + {{ s__('FeatureFlags|All users') }} + </option> + <option :value="$options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT"> + {{ s__('FeatureFlags|Percent rollout (logged in users)') }} + </option> + <option :value="$options.ROLLOUT_STRATEGY_USER_ID"> + {{ s__('FeatureFlags|User IDs') }} + </option> + </select> + <gl-icon + name="chevron-down" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + :size="16" + /> + </div> + + <div + v-if="scope.rolloutStrategy === $options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT" + class="d-flex-center mt-2 mt-md-0 ml-md-2" + > + <label class="sr-only" :for="rolloutPercentageId(index)"> + {{ s__('FeatureFlags|Rollout Percentage') }} + </label> + <div class="gl-w-9"> + <input + :id="rolloutPercentageId(index)" + v-model="scope.rolloutPercentage" + :disabled="!scope.active" + :class="{ + 'is-invalid': isRolloutPercentageInvalid(scope.rolloutPercentage), + }" + type="number" + min="0" + max="100" + :pattern="$options.rolloutPercentageRegex.source" + class="rollout-percentage js-rollout-percentage form-control text-right w-100" + /> + </div> + <gl-tooltip + v-if="isRolloutPercentageInvalid(scope.rolloutPercentage)" + :target="rolloutPercentageId(index)" + > + {{ + s__('FeatureFlags|Percent rollout must be a whole number between 0 and 100') + }} + </gl-tooltip> + <span class="ml-1">%</span> + </div> + <div class="d-flex flex-column align-items-start mt-2 w-100"> + <gl-form-checkbox + v-if="shouldDisplayIncludeUserIds(scope)" + v-model="scope.shouldIncludeUserIds" + >{{ s__('FeatureFlags|Include additional user IDs') }}</gl-form-checkbox + > + <template v-if="shouldDisplayUserIds(scope)"> + <label :for="rolloutUserId(index)" class="mb-2"> + {{ s__('FeatureFlags|User IDs') }} + </label> + <gl-form-textarea + :id="rolloutUserId(index)" + v-model="scope.rolloutUserIds" + class="w-100" + /> + </template> + </div> + </div> + </div> + + <div class="table-section section-10 text-right" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Remove') }} + </div> + <div class="table-mobile-content js-feature-flag-delete"> + <gl-button + v-if="!isAllEnvironment(scope.environmentScope) && canUpdateScope(scope)" + v-gl-tooltip + :title="s__('FeatureFlags|Remove')" + class="js-delete-scope btn-transparent pr-3 pl-3" + icon="clear" + @click="removeScope(scope)" + /> + </div> + </div> + </div> + + <div class="js-add-new-scope gl-responsive-table-row" role="row"> + <div class="table-section section-30" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Environment Spec') }} + </div> + <div class="table-mobile-content js-feature-flag-status"> + <environments-dropdown + class="js-new-scope-name col-12" + :value="newScope" + @selectEnvironment="env => createNewScope({ environmentScope: env })" + @createClicked="env => createNewScope({ environmentScope: env })" + /> + </div> + </div> + + <div class="table-section section-20 text-center" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Status') }} + </div> + <div class="table-mobile-content js-feature-flag-status"> + <toggle-button + :disabled-input="!active" + :value="false" + @change="createNewScope({ active: true })" + /> + </div> + </div> + + <div class="table-section section-40" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Rollout Strategy') }} + </div> + <div class="table-mobile-content js-rollout-strategy form-inline"> + <label class="sr-only" for="new-rollout-strategy-placeholder">{{ + s__('FeatureFlags|Rollout Strategy') + }}</label> + <div class="select-wrapper col-12 col-md-8 p-0"> + <select + id="new-rollout-strategy-placeholder" + disabled + class="form-control select-control w-100" + > + <option>{{ s__('FeatureFlags|All users') }}</option> + </select> + <gl-icon + name="chevron-down" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + :size="16" + /> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </fieldset> + + <div class="form-actions"> + <gl-button + ref="submitButton" + :disabled="readOnly" + type="button" + variant="success" + class="js-ff-submit col-xs-12" + @click="handleSubmit" + >{{ submitText }}</gl-button + > + <gl-button :href="cancelPath" class="js-ff-cancel col-xs-12 float-right"> + {{ __('Cancel') }} + </gl-button> + </div> + </form> +</template> diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue new file mode 100644 index 00000000000..f2017c22abf --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue @@ -0,0 +1,100 @@ +<script> +import { debounce } from 'lodash'; +import { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlIcon, + GlLoadingIcon, + GlSearchBoxByType, +} from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __, sprintf } from '~/locale'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; + +export default { + components: { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlSearchBoxByType, + GlIcon, + GlLoadingIcon, + }, + inject: ['environmentsEndpoint'], + data() { + return { + environmentSearch: '', + results: [], + isLoading: false, + }; + }, + translations: { + addEnvironmentsLabel: __('Add environment'), + noResultsLabel: __('No matching results'), + }, + computed: { + createEnvironmentLabel() { + return sprintf(__('Create %{environment}'), { environment: this.environmentSearch }); + }, + }, + methods: { + addEnvironment(newEnvironment) { + this.$emit('add', newEnvironment); + this.environmentSearch = ''; + this.results = []; + }, + fetchEnvironments: debounce(function debouncedFetchEnvironments() { + this.isLoading = true; + axios + .get(this.environmentsEndpoint, { params: { query: this.environmentSearch } }) + .then(({ data }) => { + this.results = data || []; + }) + .catch(() => { + createFlash(__('Something went wrong on our end. Please try again.')); + }) + .finally(() => { + this.isLoading = false; + }); + }, 250), + setFocus() { + this.$refs.searchBox.focusInput(); + }, + }, +}; +</script> +<template> + <gl-dropdown class="js-new-environments-dropdown" @shown="setFocus"> + <template #button-content> + <span class="d-md-none mr-1"> + {{ $options.translations.addEnvironmentsLabel }} + </span> + <gl-icon class="d-none d-md-inline-flex" name="plus" /> + </template> + <gl-search-box-by-type + ref="searchBox" + v-model.trim="environmentSearch" + @focus="fetchEnvironments" + @keyup="fetchEnvironments" + /> + <gl-loading-icon v-if="isLoading" /> + <gl-dropdown-item + v-for="environment in results" + v-else-if="results.length" + :key="environment" + @click="addEnvironment(environment)" + > + {{ environment }} + </gl-dropdown-item> + <template v-else-if="environmentSearch.length"> + <span ref="noResults" class="text-secondary gl-p-3"> + {{ $options.translations.noMatchingResults }} + </span> + <gl-dropdown-divider /> + <gl-dropdown-item @click="addEnvironment(environmentSearch)"> + {{ createEnvironmentLabel }} + </gl-dropdown-item> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue new file mode 100644 index 00000000000..9472eddf336 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue @@ -0,0 +1,101 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlAlert } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import FeatureFlagForm from './form.vue'; +import { + LEGACY_FLAG, + NEW_VERSION_FLAG, + NEW_FLAG_ALERT, + ROLLOUT_STRATEGY_ALL_USERS, +} from '../constants'; +import { createNewEnvironmentScope } from '../store/helpers'; + +import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + components: { + GlAlert, + FeatureFlagForm, + }, + mixins: [featureFlagsMixin()], + inject: { + showUserCallout: {}, + userCalloutId: { + default: '', + }, + userCalloutsPath: { + default: '', + }, + }, + data() { + return { + userShouldSeeNewFlagAlert: this.showUserCallout, + }; + }, + translations: { + newFlagAlert: NEW_FLAG_ALERT, + }, + computed: { + ...mapState(['error', 'path']), + scopes() { + return [ + createNewEnvironmentScope( + { + environmentScope: '*', + active: true, + }, + this.glFeatures.featureFlagsPermissions, + ), + ]; + }, + version() { + return this.hasNewVersionFlags ? NEW_VERSION_FLAG : LEGACY_FLAG; + }, + hasNewVersionFlags() { + return this.glFeatures.featureFlagsNewVersion; + }, + shouldShowNewFlagAlert() { + return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert; + }, + strategies() { + return [{ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] }]; + }, + }, + methods: { + ...mapActions(['createFeatureFlag']), + dismissNewVersionFlagAlert() { + this.userShouldSeeNewFlagAlert = false; + axios.post(this.userCalloutsPath, { + feature_name: this.userCalloutId, + }); + }, + }, +}; +</script> +<template> + <div> + <gl-alert + v-if="shouldShowNewFlagAlert" + variant="warning" + class="gl-my-5" + @dismiss="dismissNewVersionFlagAlert" + > + {{ $options.translations.newFlagAlert }} + </gl-alert> + <h3 class="page-title">{{ s__('FeatureFlags|New feature flag') }}</h3> + + <div v-if="error.length" class="alert alert-danger"> + <p v-for="(message, index) in error" :key="index" class="mb-0">{{ message }}</p> + </div> + + <feature-flag-form + :cancel-path="path" + :submit-text="s__('FeatureFlags|Create feature flag')" + :scopes="scopes" + :strategies="strategies" + :version="version" + @handleSubmit="data => createFeatureFlag(data)" + /> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/strategies/default.vue b/app/assets/javascripts/feature_flags/components/strategies/default.vue new file mode 100644 index 00000000000..cb8ffbddfbd --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/strategies/default.vue @@ -0,0 +1,10 @@ +<script> +export default { + mounted() { + this.$emit('change', { parameters: {} }); + }, + render() { + return this.$slots.default; + }, +}; +</script> diff --git a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue new file mode 100644 index 00000000000..020a0d43096 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue @@ -0,0 +1,121 @@ +<script> +import { GlFormInput, GlFormSelect } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { PERCENT_ROLLOUT_GROUP_ID } from '../../constants'; +import ParameterFormGroup from './parameter_form_group.vue'; + +export default { + components: { + GlFormInput, + GlFormSelect, + ParameterFormGroup, + }, + props: { + strategy: { + required: true, + type: Object, + }, + }, + i18n: { + percentageDescription: __('Enter a whole number between 0 and 100'), + percentageInvalid: __('Percent rollout must be a whole number between 0 and 100'), + percentageLabel: __('Percentage'), + stickinessDescription: __('Consistency guarantee method'), + stickinessLabel: __('Based on'), + }, + stickinessOptions: [ + { + value: 'DEFAULT', + text: __('Available ID'), + }, + { + value: 'USERID', + text: __('User ID'), + }, + { + value: 'SESSIONID', + text: __('Session ID'), + }, + { + value: 'RANDOM', + text: __('Random'), + }, + ], + computed: { + isValid() { + const percentageNum = Number(this.percentage); + return Number.isInteger(percentageNum) && percentageNum >= 0 && percentageNum <= 100; + }, + percentage() { + return this.strategy?.parameters?.rollout ?? '100'; + }, + stickiness() { + return this.strategy?.parameters?.stickiness ?? this.$options.stickinessOptions[0].value; + }, + }, + methods: { + onPercentageChange(value) { + this.$emit('change', { + parameters: { + groupId: PERCENT_ROLLOUT_GROUP_ID, + rollout: value, + stickiness: this.stickiness, + }, + }); + }, + onStickinessChange(value) { + this.$emit('change', { + parameters: { + groupId: PERCENT_ROLLOUT_GROUP_ID, + rollout: this.percentage, + stickiness: value, + }, + }); + }, + }, +}; +</script> +<template> + <div class="gl-display-flex"> + <div class="gl-mr-7" data-testid="strategy-flexible-rollout-percentage"> + <parameter-form-group + :label="$options.i18n.percentageLabel" + :description="isValid ? $options.i18n.percentageDescription : ''" + :invalid-feedback="$options.i18n.percentageInvalid" + :state="isValid" + > + <template #default="{ inputId }"> + <div class="gl-display-flex gl-align-items-center"> + <gl-form-input + :id="inputId" + :value="percentage" + :state="isValid" + class="rollout-percentage gl-text-right gl-w-9" + type="number" + min="0" + max="100" + @input="onPercentageChange" + /> + <span class="ml-1">%</span> + </div> + </template> + </parameter-form-group> + </div> + + <div class="gl-mr-7" data-testid="strategy-flexible-rollout-stickiness"> + <parameter-form-group + :label="$options.i18n.stickinessLabel" + :description="$options.i18n.stickinessDescription" + > + <template #default="{ inputId }"> + <gl-form-select + :id="inputId" + :value="stickiness" + :options="$options.stickinessOptions" + @change="onStickinessChange" + /> + </template> + </parameter-form-group> + </div> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue new file mode 100644 index 00000000000..ec97e8b1350 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue @@ -0,0 +1,63 @@ +<script> +import { GlFormSelect } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ParameterFormGroup from './parameter_form_group.vue'; + +export default { + components: { + GlFormSelect, + ParameterFormGroup, + }, + props: { + strategy: { + required: true, + type: Object, + }, + userLists: { + required: false, + type: Array, + default: () => [], + }, + }, + translations: { + rolloutUserListLabel: s__('FeatureFlag|List'), + rolloutUserListDescription: s__('FeatureFlag|Select a user list'), + rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'), + }, + computed: { + userListOptions() { + return this.userLists.map(({ name, id }) => ({ value: id, text: name })); + }, + hasUserLists() { + return this.userListOptions.length > 0; + }, + userListId() { + return this.strategy?.userListId ?? ''; + }, + }, + methods: { + onUserListChange(list) { + this.$emit('change', { + userListId: list, + }); + }, + }, +}; +</script> +<template> + <parameter-form-group + :state="hasUserLists" + :invalid-feedback="$options.translations.rolloutUserListNoListError" + :label="$options.translations.rolloutUserListLabel" + :description="hasUserLists ? $options.translations.rolloutUserListDescription : ''" + > + <template #default="{ inputId }"> + <gl-form-select + :id="inputId" + :value="userListId" + :options="userListOptions" + @change="onUserListChange" + /> + </template> + </parameter-form-group> +</template> diff --git a/app/assets/javascripts/feature_flags/components/strategies/parameter_form_group.vue b/app/assets/javascripts/feature_flags/components/strategies/parameter_form_group.vue new file mode 100644 index 00000000000..7f2c6d55db8 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/strategies/parameter_form_group.vue @@ -0,0 +1,22 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlFormGroup } from '@gitlab/ui'; + +export default { + components: { + GlFormGroup, + }, + props: { + inputId: { + required: false, + type: String, + default: () => uniqueId('feature_flag_strategies_'), + }, + }, +}; +</script> +<template> + <gl-form-group :label-for="inputId" v-bind="$attrs"> + <slot v-bind="{ inputId }"></slot> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue new file mode 100644 index 00000000000..d262769c891 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue @@ -0,0 +1,69 @@ +<script> +import { GlFormInput } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import { PERCENT_ROLLOUT_GROUP_ID } from '../../constants'; +import ParameterFormGroup from './parameter_form_group.vue'; + +export default { + components: { + GlFormInput, + ParameterFormGroup, + }, + props: { + strategy: { + required: true, + type: Object, + }, + }, + i18n: { + rolloutPercentageDescription: __('Enter a whole number between 0 and 100'), + rolloutPercentageInvalid: s__( + 'FeatureFlags|Percent rollout must be a whole number between 0 and 100', + ), + rolloutPercentageLabel: s__('FeatureFlag|Percentage'), + }, + computed: { + isValid() { + const percentageNum = Number(this.percentage); + return Number.isInteger(percentageNum) && percentageNum >= 0 && percentageNum <= 100; + }, + percentage() { + return this.strategy?.parameters?.percentage ?? '100'; + }, + }, + methods: { + onPercentageChange(value) { + this.$emit('change', { + parameters: { + percentage: value, + groupId: PERCENT_ROLLOUT_GROUP_ID, + }, + }); + }, + }, +}; +</script> +<template> + <parameter-form-group + :label="$options.i18n.rolloutPercentageLabel" + :description="isValid ? $options.i18n.rolloutPercentageDescription : ''" + :invalid-feedback="$options.i18n.rolloutPercentageInvalid" + :state="isValid" + > + <template #default="{ inputId }"> + <div class="gl-display-flex gl-align-items-center"> + <gl-form-input + :id="inputId" + :value="percentage" + :state="isValid" + class="rollout-percentage gl-text-right gl-w-9" + type="number" + min="0" + max="100" + @input="onPercentageChange" + /> + <span class="gl-ml-2">%</span> + </div> + </template> + </parameter-form-group> +</template> diff --git a/app/assets/javascripts/feature_flags/components/strategies/users_with_id.vue b/app/assets/javascripts/feature_flags/components/strategies/users_with_id.vue new file mode 100644 index 00000000000..094127fb710 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/strategies/users_with_id.vue @@ -0,0 +1,47 @@ +<script> +import { GlFormTextarea } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +import ParameterFormGroup from './parameter_form_group.vue'; + +export default { + components: { + ParameterFormGroup, + GlFormTextarea, + }, + props: { + strategy: { + required: true, + type: Object, + }, + }, + translations: { + rolloutUserIdsDescription: __('Enter one or more user ID separated by commas'), + rolloutUserIdsLabel: s__('FeatureFlag|User IDs'), + }, + computed: { + userIds() { + return this.strategy?.parameters?.userIds ?? ''; + }, + }, + methods: { + onUserIdsChange(value) { + this.$emit('change', { + parameters: { + userIds: value, + }, + }); + }, + }, +}; +</script> +<template> + <parameter-form-group + :label="$options.translations.rolloutUserIdsLabel" + :description="$options.translations.rolloutUserIdsDescription" + > + <template #default="{ inputId }"> + <gl-form-textarea :id="inputId" :value="userIds" @input="onUserIdsChange" /> + </template> + </parameter-form-group> +</template> diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue new file mode 100644 index 00000000000..9c41dde62e4 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/strategy.vue @@ -0,0 +1,206 @@ +<script> +import Vue from 'vue'; +import { isNumber } from 'lodash'; +import { GlAlert, GlButton, GlFormSelect, GlFormGroup, GlIcon, GlLink, GlToken } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import { + EMPTY_PARAMETERS, + STRATEGY_SELECTIONS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, +} from '../constants'; + +import NewEnvironmentsDropdown from './new_environments_dropdown.vue'; +import StrategyParameters from './strategy_parameters.vue'; + +export default { + components: { + GlAlert, + GlButton, + GlFormGroup, + GlFormSelect, + GlIcon, + GlLink, + GlToken, + NewEnvironmentsDropdown, + StrategyParameters, + }, + inject: { + strategyTypeDocsPagePath: { + default: '', + }, + environmentsScopeDocsPath: { + default: '', + }, + }, + props: { + strategy: { + type: Object, + required: true, + }, + index: { + type: Number, + required: true, + }, + userLists: { + type: Array, + required: false, + default: () => [], + }, + }, + + i18n: { + allEnvironments: __('All environments'), + environmentsLabel: __('Environments'), + strategyTypeDescription: __('Select strategy activation method'), + strategyTypeLabel: s__('FeatureFlag|Type'), + environmentsSelectDescription: s__( + 'FeatureFlag|Select the environment scope for this feature flag', + ), + considerFlexibleRollout: s__( + 'FeatureFlags|Consider using the more flexible "Percent rollout" strategy instead.', + ), + }, + + strategies: STRATEGY_SELECTIONS, + + data() { + return { + environments: this.strategy.scopes || [], + formStrategy: { ...this.strategy }, + }; + }, + computed: { + strategyTypeId() { + return `strategy-type-${this.index}`; + }, + environmentsDropdownId() { + return `environments-dropdown-${this.index}`; + }, + appliesToAllEnvironments() { + return ( + this.filteredEnvironments.length === 1 && + this.filteredEnvironments[0].environmentScope === '*' + ); + }, + filteredEnvironments() { + return this.environments.filter(e => !e.shouldBeDestroyed); + }, + isPercentUserRollout() { + return this.formStrategy.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT; + }, + }, + methods: { + addEnvironment(environment) { + const allEnvironmentsScope = this.environments.find(scope => scope.environmentScope === '*'); + if (allEnvironmentsScope) { + allEnvironmentsScope.shouldBeDestroyed = true; + } + this.environments.push({ environmentScope: environment }); + this.onStrategyChange({ ...this.formStrategy, scopes: this.environments }); + }, + onStrategyTypeChange(name) { + this.onStrategyChange({ + ...this.formStrategy, + ...EMPTY_PARAMETERS, + name, + }); + }, + onStrategyChange(s) { + this.$emit('change', s); + this.formStrategy = s; + }, + removeScope(environment) { + if (isNumber(environment.id)) { + Vue.set(environment, 'shouldBeDestroyed', true); + } else { + this.environments = this.environments.filter(e => e !== environment); + } + if (this.filteredEnvironments.length === 0) { + this.environments.push({ environmentScope: '*' }); + } + this.onStrategyChange({ ...this.formStrategy, scopes: this.environments }); + }, + }, +}; +</script> +<template> + <div> + <gl-alert v-if="isPercentUserRollout" variant="tip" :dismissible="false"> + {{ $options.i18n.considerFlexibleRollout }} + </gl-alert> + + <div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-py-6"> + <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row flex-md-wrap"> + <div class="mr-5"> + <gl-form-group :label="$options.i18n.strategyTypeLabel" :label-for="strategyTypeId"> + <template #description> + {{ $options.i18n.strategyTypeDescription }} + <gl-link :href="strategyTypeDocsPagePath" target="_blank"> + <gl-icon name="question" /> + </gl-link> + </template> + <gl-form-select + :id="strategyTypeId" + :value="formStrategy.name" + :options="$options.strategies" + @change="onStrategyTypeChange" + /> + </gl-form-group> + </div> + + <div data-testid="strategy"> + <strategy-parameters + :strategy="strategy" + :user-lists="userLists" + @change="onStrategyChange" + /> + </div> + + <div + class="align-self-end align-self-md-stretch order-first offset-md-0 order-md-0 gl-ml-auto" + > + <gl-button + data-testid="delete-strategy-button" + variant="danger" + icon="remove" + @click="$emit('delete')" + /> + </div> + </div> + + <label class="gl-display-block" :for="environmentsDropdownId">{{ + $options.i18n.environmentsLabel + }}</label> + <div class="gl-display-flex gl-flex-direction-column"> + <div + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row align-items-start gl-md-align-items-center" + > + <new-environments-dropdown + :id="environmentsDropdownId" + class="gl-mr-3" + @add="addEnvironment" + /> + <span v-if="appliesToAllEnvironments" class="text-secondary gl-mt-3 mt-md-0 ml-md-3"> + {{ $options.i18n.allEnvironments }} + </span> + <div v-else class="gl-display-flex gl-align-items-center"> + <gl-token + v-for="environment in filteredEnvironments" + :key="environment.id" + class="gl-mt-3 gl-mr-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill" + @close="removeScope(environment)" + > + {{ environment.environmentScope }} + </gl-token> + </div> + </div> + </div> + <span class="gl-display-inline-block gl-py-3"> + {{ $options.i18n.environmentsSelectDescription }} + </span> + <gl-link :href="environmentsScopeDocsPath" target="_blank"> + <gl-icon name="question" /> + </gl-link> + </div> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/strategy_parameters.vue b/app/assets/javascripts/feature_flags/components/strategy_parameters.vue new file mode 100644 index 00000000000..b6e06880315 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/strategy_parameters.vue @@ -0,0 +1,54 @@ +<script> +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, +} from '../constants'; + +import Default from './strategies/default.vue'; +import FlexibleRollout from './strategies/flexible_rollout.vue'; +import PercentRollout from './strategies/percent_rollout.vue'; +import UsersWithId from './strategies/users_with_id.vue'; +import GitlabUserList from './strategies/gitlab_user_list.vue'; + +const STRATEGIES = Object.freeze({ + [ROLLOUT_STRATEGY_ALL_USERS]: Default, + [ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT]: FlexibleRollout, + [ROLLOUT_STRATEGY_PERCENT_ROLLOUT]: PercentRollout, + [ROLLOUT_STRATEGY_USER_ID]: UsersWithId, + [ROLLOUT_STRATEGY_GITLAB_USER_LIST]: GitlabUserList, +}); + +export default { + props: { + strategy: { + type: Object, + required: true, + }, + }, + computed: { + strategyComponent() { + return STRATEGIES[(this.strategy?.name)]; + }, + }, + methods: { + onChange(value) { + this.$emit('change', { + ...this.strategy, + ...value, + }); + }, + }, +}; +</script> +<template> + <component + :is="strategyComponent" + v-if="strategyComponent" + :strategy="strategy" + v-bind="$attrs" + @change="onChange" + /> +</template> diff --git a/app/assets/javascripts/feature_flags/components/user_lists_table.vue b/app/assets/javascripts/feature_flags/components/user_lists_table.vue new file mode 100644 index 00000000000..0bfd18f992c --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/user_lists_table.vue @@ -0,0 +1,122 @@ +<script> +import { + GlButton, + GlButtonGroup, + GlModal, + GlSprintf, + GlTooltipDirective, + GlModalDirective, +} from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + components: { GlButton, GlButtonGroup, GlModal, GlSprintf }, + directives: { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective }, + mixins: [timeagoMixin], + props: { + userLists: { + type: Array, + required: true, + }, + }, + translations: { + createdTimeagoLabel: s__('UserList|created %{timeago}'), + deleteListTitle: s__('UserList|Delete %{name}?'), + deleteListMessage: s__('User list %{name} will be removed. Are you sure?'), + }, + modal: { + id: 'deleteListModal', + actionPrimary: { + text: s__('Delete user list'), + attributes: { variant: 'danger', 'data-testid': 'modal-confirm' }, + }, + }, + data() { + return { + deleteUserList: null, + }; + }, + computed: { + deleteListName() { + return this.deleteUserList?.name; + }, + modalTitle() { + return sprintf(this.$options.translations.deleteListTitle, { + name: this.deleteListName, + }); + }, + }, + methods: { + createdTimeago(list) { + return sprintf(this.$options.translations.createdTimeagoLabel, { + timeago: this.timeFormatted(list.created_at), + }); + }, + displayList(list) { + return list.user_xids.replace(/,/g, ', '); + }, + onDelete() { + this.$emit('delete', this.deleteUserList); + }, + confirmDeleteList(list) { + this.deleteUserList = list; + }, + }, +}; +</script> +<template> + <div> + <div + v-for="list in userLists" + :key="list.id" + data-testid="ffUserList" + class="gl-border-b-solid gl-border-gray-100 gl-border-b-1 gl-w-full gl-py-4 gl-display-flex gl-justify-content-space-between" + > + <div class="gl-display-flex gl-flex-direction-column gl-overflow-hidden gl-flex-grow-1"> + <span data-testid="ffUserListName" class="gl-font-weight-bold gl-mb-2"> + {{ list.name }} + </span> + <span + v-gl-tooltip + :title="tooltipTitle(list.created_at)" + data-testid="ffUserListTimestamp" + class="gl-text-gray-300 gl-mb-2" + > + {{ createdTimeago(list) }} + </span> + <span data-testid="ffUserListIds" class="gl-str-truncated">{{ displayList(list) }}</span> + </div> + + <gl-button-group class="gl-align-self-start gl-mt-2"> + <gl-button + :href="list.path" + category="secondary" + icon="pencil" + data-testid="edit-user-list" + /> + <gl-button + v-gl-modal="$options.modal.id" + category="secondary" + variant="danger" + icon="remove" + data-testid="delete-user-list" + @click="confirmDeleteList(list)" + /> + </gl-button-group> + </div> + <gl-modal + :title="modalTitle" + :modal-id="$options.modal.id" + :action-primary="$options.modal.actionPrimary" + static + @primary="onDelete" + > + <gl-sprintf :message="$options.translations.deleteListMessage"> + <template #name> + <b>{{ deleteListName }}</b> + </template> + </gl-sprintf> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js new file mode 100644 index 00000000000..4843eca149a --- /dev/null +++ b/app/assets/javascripts/feature_flags/constants.js @@ -0,0 +1,54 @@ +import { property } from 'lodash'; +import { s__ } from '~/locale'; + +export const ROLLOUT_STRATEGY_ALL_USERS = 'default'; +export const ROLLOUT_STRATEGY_PERCENT_ROLLOUT = 'gradualRolloutUserId'; +export const ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT = 'flexibleRollout'; +export const ROLLOUT_STRATEGY_USER_ID = 'userWithId'; +export const ROLLOUT_STRATEGY_GITLAB_USER_LIST = 'gitlabUserList'; + +export const PERCENT_ROLLOUT_GROUP_ID = 'default'; + +export const DEFAULT_PERCENT_ROLLOUT = '100'; + +export const ALL_ENVIRONMENTS_NAME = '*'; + +export const INTERNAL_ID_PREFIX = 'internal_'; + +export const fetchPercentageParams = property(['parameters', 'percentage']); +export const fetchUserIdParams = property(['parameters', 'userIds']); + +export const NEW_VERSION_FLAG = 'new_version_flag'; +export const LEGACY_FLAG = 'legacy_flag'; + +export const NEW_FLAG_ALERT = s__( + 'FeatureFlags|Feature Flags will look different in the next milestone. No action is needed, but you may notice the functionality was changed to improve the workflow.', +); + +export const FEATURE_FLAG_SCOPE = 'featureFlags'; +export const USER_LIST_SCOPE = 'userLists'; + +export const EMPTY_PARAMETERS = { parameters: {}, userListId: undefined }; + +export const STRATEGY_SELECTIONS = [ + { + value: ROLLOUT_STRATEGY_ALL_USERS, + text: s__('FeatureFlags|All users'), + }, + { + value: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + text: s__('FeatureFlags|Percent rollout'), + }, + { + value: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + text: s__('FeatureFlags|Percent of users'), + }, + { + value: ROLLOUT_STRATEGY_USER_ID, + text: s__('FeatureFlags|User IDs'), + }, + { + value: ROLLOUT_STRATEGY_GITLAB_USER_LIST, + text: s__('FeatureFlags|User List'), + }, +]; diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js new file mode 100644 index 00000000000..b4d2111acf3 --- /dev/null +++ b/app/assets/javascripts/feature_flags/edit.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import createStore from './store/edit'; +import EditFeatureFlag from './components/edit_feature_flag.vue'; + +Vue.use(Vuex); + +export default () => { + const el = document.querySelector('#js-edit-feature-flag'); + const { + environmentsScopeDocsPath, + strategyTypeDocsPagePath, + endpoint, + featureFlagsPath, + environmentsEndpoint, + projectId, + featureFlagIssuesEndpoint, + userCalloutsPath, + userCalloutId, + showUserCallout, + } = el.dataset; + + return new Vue({ + store: createStore({ endpoint, path: featureFlagsPath }), + el, + provide: { + environmentsScopeDocsPath, + strategyTypeDocsPagePath, + environmentsEndpoint, + projectId, + featureFlagIssuesEndpoint, + userCalloutsPath, + userCalloutId, + showUserCallout: parseBoolean(showUserCallout), + }, + render(createElement) { + return createElement(EditFeatureFlag); + }, + }); +}; diff --git a/app/assets/javascripts/feature_flags/index.js b/app/assets/javascripts/feature_flags/index.js new file mode 100644 index 00000000000..a92805d5d85 --- /dev/null +++ b/app/assets/javascripts/feature_flags/index.js @@ -0,0 +1,49 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import csrf from '~/lib/utils/csrf'; +import FeatureFlagsComponent from './components/feature_flags.vue'; +import createStore from './store/index'; + +Vue.use(Vuex); + +export default () => { + const el = document.querySelector('#feature-flags-vue'); + + const { + projectName, + featureFlagsHelpPagePath, + errorStateSvgPath, + endpoint, + projectId, + unleashApiInstanceId, + rotateInstanceIdPath, + featureFlagsClientLibrariesHelpPagePath, + featureFlagsClientExampleHelpPagePath, + unleashApiUrl, + canUserAdminFeatureFlag, + newFeatureFlagPath, + newUserListPath, + featureFlagsLimitExceeded, + } = el.dataset; + + return new Vue({ + el, + store: createStore({ endpoint, projectId, unleashApiInstanceId, rotateInstanceIdPath }), + provide: { + projectName, + featureFlagsHelpPagePath, + errorStateSvgPath, + featureFlagsClientLibrariesHelpPagePath, + featureFlagsClientExampleHelpPagePath, + unleashApiUrl, + csrfToken: csrf.token, + canUserConfigure: canUserAdminFeatureFlag !== undefined, + newFeatureFlagPath, + newUserListPath, + featureFlagsLimitExceeded, + }, + render(createElement) { + return createElement(FeatureFlagsComponent); + }, + }); +}; diff --git a/app/assets/javascripts/feature_flags/new.js b/app/assets/javascripts/feature_flags/new.js new file mode 100644 index 00000000000..a1efbd87ec4 --- /dev/null +++ b/app/assets/javascripts/feature_flags/new.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import createStore from './store/new'; +import NewFeatureFlag from './components/new_feature_flag.vue'; + +Vue.use(Vuex); + +export default () => { + const el = document.querySelector('#js-new-feature-flag'); + const { + environmentsScopeDocsPath, + strategyTypeDocsPagePath, + endpoint, + featureFlagsPath, + environmentsEndpoint, + projectId, + userCalloutsPath, + userCalloutId, + showUserCallout, + } = el.dataset; + + return new Vue({ + el, + store: createStore({ endpoint, path: featureFlagsPath }), + provide: { + environmentsScopeDocsPath, + strategyTypeDocsPagePath, + environmentsEndpoint, + projectId, + userCalloutsPath, + userCalloutId, + showUserCallout: parseBoolean(showUserCallout), + }, + render(createElement) { + return createElement(NewFeatureFlag); + }, + }); +}; diff --git a/app/assets/javascripts/feature_flags/store/edit/actions.js b/app/assets/javascripts/feature_flags/store/edit/actions.js new file mode 100644 index 00000000000..3678c2f7788 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/edit/actions.js @@ -0,0 +1,61 @@ +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { __ } from '~/locale'; +import { NEW_VERSION_FLAG } from '../../constants'; +import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers'; + +/** + * Handles the edition of a feature flag. + * + * Will dispatch `requestUpdateFeatureFlag` + * Serializes the params and makes a put request + * Dispatches an action acording to the request status. + * + * @param {Object} params + */ +export const updateFeatureFlag = ({ state, dispatch }, params) => { + dispatch('requestUpdateFeatureFlag'); + + axios + .put( + state.endpoint, + params.version === NEW_VERSION_FLAG + ? mapStrategiesToRails(params) + : mapFromScopesViewModel(params), + ) + .then(() => { + dispatch('receiveUpdateFeatureFlagSuccess'); + visitUrl(state.path); + }) + .catch(error => dispatch('receiveUpdateFeatureFlagError', error.response.data)); +}; + +export const requestUpdateFeatureFlag = ({ commit }) => commit(types.REQUEST_UPDATE_FEATURE_FLAG); +export const receiveUpdateFeatureFlagSuccess = ({ commit }) => + commit(types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS); +export const receiveUpdateFeatureFlagError = ({ commit }, error) => + commit(types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, error); + +/** + * Fetches the feature flag data for the edit form + */ +export const fetchFeatureFlag = ({ state, dispatch }) => { + dispatch('requestFeatureFlag'); + + axios + .get(state.endpoint) + .then(({ data }) => dispatch('receiveFeatureFlagSuccess', data)) + .catch(() => dispatch('receiveFeatureFlagError')); +}; + +export const requestFeatureFlag = ({ commit }) => commit(types.REQUEST_FEATURE_FLAG); +export const receiveFeatureFlagSuccess = ({ commit }, response) => + commit(types.RECEIVE_FEATURE_FLAG_SUCCESS, response); +export const receiveFeatureFlagError = ({ commit }) => { + commit(types.RECEIVE_FEATURE_FLAG_ERROR); + createFlash(__('Something went wrong on our end. Please try again!')); +}; + +export const toggleActive = ({ commit }, active) => commit(types.TOGGLE_ACTIVE, active); diff --git a/app/assets/javascripts/feature_flags/store/edit/index.js b/app/assets/javascripts/feature_flags/store/edit/index.js new file mode 100644 index 00000000000..f737e0517fc --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/edit/index.js @@ -0,0 +1,11 @@ +import Vuex from 'vuex'; +import state from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default data => + new Vuex.Store({ + actions, + mutations, + state: state(data), + }); diff --git a/app/assets/javascripts/feature_flags/store/edit/mutation_types.js b/app/assets/javascripts/feature_flags/store/edit/mutation_types.js new file mode 100644 index 00000000000..c215dad3513 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/edit/mutation_types.js @@ -0,0 +1,9 @@ +export const REQUEST_UPDATE_FEATURE_FLAG = 'REQUEST_UPDATE_FEATURE_FLAG'; +export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS'; +export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR'; + +export const REQUEST_FEATURE_FLAG = 'REQUEST_FEATURE_FLAG'; +export const RECEIVE_FEATURE_FLAG_SUCCESS = 'RECEIVE_FEATURE_FLAG_SUCCESS'; +export const RECEIVE_FEATURE_FLAG_ERROR = 'RECEIVE_FEATURE_FLAG_ERROR'; + +export const TOGGLE_ACTIVE = 'TOGGLE_ACTIVE'; diff --git a/app/assets/javascripts/feature_flags/store/edit/mutations.js b/app/assets/javascripts/feature_flags/store/edit/mutations.js new file mode 100644 index 00000000000..e60dbaf4a34 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/edit/mutations.js @@ -0,0 +1,39 @@ +import * as types from './mutation_types'; +import { mapToScopesViewModel, mapStrategiesToViewModel } from '../helpers'; +import { LEGACY_FLAG } from '../../constants'; + +export default { + [types.REQUEST_FEATURE_FLAG](state) { + state.isLoading = true; + }, + [types.RECEIVE_FEATURE_FLAG_SUCCESS](state, response) { + state.isLoading = false; + state.hasError = false; + + state.name = response.name; + state.description = response.description; + state.iid = response.iid; + state.active = response.active; + state.scopes = mapToScopesViewModel(response.scopes); + state.strategies = mapStrategiesToViewModel(response.strategies); + state.version = response.version || LEGACY_FLAG; + }, + [types.RECEIVE_FEATURE_FLAG_ERROR](state) { + state.isLoading = false; + state.hasError = true; + }, + [types.REQUEST_UPDATE_FEATURE_FLAG](state) { + state.isSendingRequest = true; + state.error = []; + }, + [types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](state) { + state.isSendingRequest = false; + }, + [types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, error) { + state.isSendingRequest = false; + state.error = error.message || []; + }, + [types.TOGGLE_ACTIVE](state, active) { + state.active = active; + }, +}; diff --git a/app/assets/javascripts/feature_flags/store/edit/state.js b/app/assets/javascripts/feature_flags/store/edit/state.js new file mode 100644 index 00000000000..ec507532d6a --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/edit/state.js @@ -0,0 +1,18 @@ +import { LEGACY_FLAG } from '../../constants'; + +export default ({ path, endpoint }) => ({ + endpoint, + path, + isSendingRequest: false, + error: [], + + name: null, + description: null, + scopes: [], + isLoading: false, + hasError: false, + iid: null, + active: true, + strategies: [], + version: LEGACY_FLAG, +}); diff --git a/app/assets/javascripts/feature_flags/store/helpers.js b/app/assets/javascripts/feature_flags/store/helpers.js new file mode 100644 index 00000000000..db6da815abf --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/helpers.js @@ -0,0 +1,213 @@ +import { isEmpty, uniqueId, isString } from 'lodash'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, + INTERNAL_ID_PREFIX, + DEFAULT_PERCENT_ROLLOUT, + PERCENT_ROLLOUT_GROUP_ID, + fetchPercentageParams, + fetchUserIdParams, + LEGACY_FLAG, +} from '../constants'; + +/** + * Converts raw scope objects fetched from the API into an array of scope + * objects that is easier/nicer to bind to in Vue. + * @param {Array} scopesFromRails An array of scope objects fetched from the API + */ +export const mapToScopesViewModel = scopesFromRails => + (scopesFromRails || []).map(s => { + const percentStrategy = (s.strategies || []).find( + strat => strat.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ); + + const rolloutPercentage = fetchPercentageParams(percentStrategy) || DEFAULT_PERCENT_ROLLOUT; + + const userStrategy = (s.strategies || []).find( + strat => strat.name === ROLLOUT_STRATEGY_USER_ID, + ); + + const rolloutStrategy = + (percentStrategy && percentStrategy.name) || + (userStrategy && userStrategy.name) || + ROLLOUT_STRATEGY_ALL_USERS; + + const rolloutUserIds = (fetchUserIdParams(userStrategy) || '') + .split(',') + .filter(id => id) + .join(', '); + + return { + id: s.id, + environmentScope: s.environment_scope, + active: Boolean(s.active), + canUpdate: Boolean(s.can_update), + protected: Boolean(s.protected), + rolloutStrategy, + rolloutPercentage, + rolloutUserIds, + + // eslint-disable-next-line no-underscore-dangle + shouldBeDestroyed: Boolean(s._destroy), + shouldIncludeUserIds: rolloutUserIds.length > 0 && percentStrategy !== null, + }; + }); +/** + * Converts the parameters emitted by the Vue component into + * the shape that the Rails API expects. + * @param {Array} scopesFromVue An array of scope objects from the Vue component + */ +export const mapFromScopesViewModel = params => { + const scopes = (params.scopes || []).map(s => { + const parameters = {}; + if (s.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT) { + parameters.groupId = PERCENT_ROLLOUT_GROUP_ID; + parameters.percentage = s.rolloutPercentage; + } else if (s.rolloutStrategy === ROLLOUT_STRATEGY_USER_ID) { + parameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ','); + } + + const userIdParameters = {}; + + if (s.shouldIncludeUserIds && s.rolloutStrategy !== ROLLOUT_STRATEGY_USER_ID) { + userIdParameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ','); + } + + // Strip out any internal IDs + const id = isString(s.id) && s.id.startsWith(INTERNAL_ID_PREFIX) ? undefined : s.id; + + const strategies = [ + { + name: s.rolloutStrategy, + parameters, + }, + ]; + + if (!isEmpty(userIdParameters)) { + strategies.push({ name: ROLLOUT_STRATEGY_USER_ID, parameters: userIdParameters }); + } + + return { + id, + environment_scope: s.environmentScope, + active: s.active, + can_update: s.canUpdate, + protected: s.protected, + _destroy: s.shouldBeDestroyed, + strategies, + }; + }); + + const model = { + operations_feature_flag: { + name: params.name, + description: params.description, + active: params.active, + scopes_attributes: scopes, + version: LEGACY_FLAG, + }, + }; + + return model; +}; + +/** + * Creates a new feature flag environment scope object for use + * in a Vue component. An optional parameter can be passed to + * override the property values that are created by default. + * + * @param {Object} overrides An optional object whose + * property values will be used to override the default values. + * + */ +export const createNewEnvironmentScope = (overrides = {}, featureFlagPermissions = false) => { + const defaultScope = { + environmentScope: '', + active: false, + id: uniqueId(INTERNAL_ID_PREFIX), + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + rolloutUserIds: '', + }; + + const newScope = { + ...defaultScope, + ...overrides, + }; + + if (featureFlagPermissions) { + newScope.canUpdate = true; + newScope.protected = false; + } + + return newScope; +}; + +const mapStrategyScopesToRails = scopes => + scopes.length === 0 + ? [{ environment_scope: '*' }] + : scopes.map(s => ({ + id: s.id, + _destroy: s.shouldBeDestroyed, + environment_scope: s.environmentScope, + })); + +const mapStrategyScopesToView = scopes => + scopes.map(s => ({ + id: s.id, + // eslint-disable-next-line no-underscore-dangle + shouldBeDestroyed: Boolean(s._destroy), + environmentScope: s.environment_scope, + })); + +const mapStrategiesParametersToViewModel = params => { + if (params.userIds) { + return { ...params, userIds: params.userIds.split(',').join(', ') }; + } + return params; +}; + +export const mapStrategiesToViewModel = strategiesFromRails => + (strategiesFromRails || []).map(s => ({ + id: s.id, + name: s.name, + parameters: mapStrategiesParametersToViewModel(s.parameters), + userListId: s.user_list?.id, + // eslint-disable-next-line no-underscore-dangle + shouldBeDestroyed: Boolean(s._destroy), + scopes: mapStrategyScopesToView(s.scopes), + })); + +const mapStrategiesParametersToRails = params => { + if (params.userIds) { + return { ...params, userIds: params.userIds.replace(/\s*,\s*/g, ',') }; + } + return params; +}; + +const mapStrategyToRails = strategy => { + const mappedStrategy = { + id: strategy.id, + name: strategy.name, + _destroy: strategy.shouldBeDestroyed, + scopes_attributes: mapStrategyScopesToRails(strategy.scopes || []), + parameters: mapStrategiesParametersToRails(strategy.parameters), + }; + + if (strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST) { + mappedStrategy.user_list_id = strategy.userListId; + } + return mappedStrategy; +}; + +export const mapStrategiesToRails = params => ({ + operations_feature_flag: { + name: params.name, + description: params.description, + version: params.version, + active: params.active, + strategies_attributes: (params.strategies || []).map(mapStrategyToRails), + }, +}); diff --git a/app/assets/javascripts/feature_flags/store/index/actions.js b/app/assets/javascripts/feature_flags/store/index/actions.js new file mode 100644 index 00000000000..a8c1a72c016 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/index/actions.js @@ -0,0 +1,97 @@ +import Api from '~/api'; +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; + +export const setFeatureFlagsOptions = ({ commit }, options) => + commit(types.SET_FEATURE_FLAGS_OPTIONS, options); + +export const fetchFeatureFlags = ({ state, dispatch }) => { + dispatch('requestFeatureFlags'); + + axios + .get(state.endpoint, { + params: state.options, + }) + .then(response => + dispatch('receiveFeatureFlagsSuccess', { + data: response.data || {}, + headers: response.headers, + }), + ) + .catch(() => dispatch('receiveFeatureFlagsError')); +}; + +export const requestFeatureFlags = ({ commit }) => commit(types.REQUEST_FEATURE_FLAGS); +export const receiveFeatureFlagsSuccess = ({ commit }, response) => + commit(types.RECEIVE_FEATURE_FLAGS_SUCCESS, response); +export const receiveFeatureFlagsError = ({ commit }) => commit(types.RECEIVE_FEATURE_FLAGS_ERROR); + +export const fetchUserLists = ({ state, dispatch }) => { + dispatch('requestUserLists'); + + return Api.fetchFeatureFlagUserLists(state.projectId, state.options.page) + .then(({ data, headers }) => dispatch('receiveUserListsSuccess', { data, headers })) + .catch(() => dispatch('receiveUserListsError')); +}; + +export const requestUserLists = ({ commit }) => commit(types.REQUEST_USER_LISTS); +export const receiveUserListsSuccess = ({ commit }, response) => + commit(types.RECEIVE_USER_LISTS_SUCCESS, response); +export const receiveUserListsError = ({ commit }) => commit(types.RECEIVE_USER_LISTS_ERROR); + +export const toggleFeatureFlag = ({ dispatch }, flag) => { + dispatch('updateFeatureFlag', flag); + + axios + .put(flag.update_path, { + operations_feature_flag: flag, + }) + .then(response => dispatch('receiveUpdateFeatureFlagSuccess', response.data)) + .catch(() => dispatch('receiveUpdateFeatureFlagError', flag.id)); +}; + +export const updateFeatureFlag = ({ commit }, flag) => commit(types.UPDATE_FEATURE_FLAG, flag); + +export const receiveUpdateFeatureFlagSuccess = ({ commit }, data) => + commit(types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS, data); +export const receiveUpdateFeatureFlagError = ({ commit }, id) => + commit(types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, id); + +export const deleteUserList = ({ state, dispatch }, list) => { + dispatch('requestDeleteUserList', list); + + return Api.deleteFeatureFlagUserList(state.projectId, list.iid) + .then(() => dispatch('fetchUserLists')) + .catch(error => + dispatch('receiveDeleteUserListError', { + list, + error: error?.response?.data ?? error, + }), + ); +}; + +export const requestDeleteUserList = ({ commit }, list) => + commit(types.REQUEST_DELETE_USER_LIST, list); + +export const receiveDeleteUserListError = ({ commit }, { error, list }) => { + commit(types.RECEIVE_DELETE_USER_LIST_ERROR, { error, list }); +}; + +export const rotateInstanceId = ({ state, dispatch }) => { + dispatch('requestRotateInstanceId'); + + axios + .post(state.rotateEndpoint) + .then(({ data = {}, headers }) => dispatch('receiveRotateInstanceIdSuccess', { data, headers })) + .catch(() => dispatch('receiveRotateInstanceIdError')); +}; + +export const requestRotateInstanceId = ({ commit }) => commit(types.REQUEST_ROTATE_INSTANCE_ID); +export const receiveRotateInstanceIdSuccess = ({ commit }, response) => + commit(types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS, response); +export const receiveRotateInstanceIdError = ({ commit }) => + commit(types.RECEIVE_ROTATE_INSTANCE_ID_ERROR); + +export const clearAlert = ({ commit }, index) => { + commit(types.RECEIVE_CLEAR_ALERT, index); +}; diff --git a/app/assets/javascripts/feature_flags/store/index/index.js b/app/assets/javascripts/feature_flags/store/index/index.js new file mode 100644 index 00000000000..f737e0517fc --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/index/index.js @@ -0,0 +1,11 @@ +import Vuex from 'vuex'; +import state from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default data => + new Vuex.Store({ + actions, + mutations, + state: state(data), + }); diff --git a/app/assets/javascripts/feature_flags/store/index/mutation_types.js b/app/assets/javascripts/feature_flags/store/index/mutation_types.js new file mode 100644 index 00000000000..189c763782e --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/index/mutation_types.js @@ -0,0 +1,22 @@ +export const SET_FEATURE_FLAGS_OPTIONS = 'SET_FEATURE_FLAGS_OPTIONS'; + +export const REQUEST_FEATURE_FLAGS = 'REQUEST_FEATURE_FLAGS'; +export const RECEIVE_FEATURE_FLAGS_SUCCESS = 'RECEIVE_FEATURE_FLAGS_SUCCESS'; +export const RECEIVE_FEATURE_FLAGS_ERROR = 'RECEIVE_FEATURE_FLAGS_ERROR'; + +export const REQUEST_USER_LISTS = 'REQUEST_USER_LISTS'; +export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS'; +export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR'; + +export const REQUEST_DELETE_USER_LIST = 'REQUEST_DELETE_USER_LIST'; +export const RECEIVE_DELETE_USER_LIST_ERROR = 'RECEIVE_DELETE_USER_LIST_ERROR'; + +export const UPDATE_FEATURE_FLAG = 'UPDATE_FEATURE_FLAG'; +export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS'; +export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR'; + +export const REQUEST_ROTATE_INSTANCE_ID = 'REQUEST_ROTATE_INSTANCE_ID'; +export const RECEIVE_ROTATE_INSTANCE_ID_SUCCESS = 'RECEIVE_ROTATE_INSTANCE_ID_SUCCESS'; +export const RECEIVE_ROTATE_INSTANCE_ID_ERROR = 'RECEIVE_ROTATE_INSTANCE_ID_ERROR'; + +export const RECEIVE_CLEAR_ALERT = 'RECEIVE_CLEAR_ALERT'; diff --git a/app/assets/javascripts/feature_flags/store/index/mutations.js b/app/assets/javascripts/feature_flags/store/index/mutations.js new file mode 100644 index 00000000000..bdc23e66214 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/index/mutations.js @@ -0,0 +1,113 @@ +import Vue from 'vue'; +import * as types from './mutation_types'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../constants'; +import { mapToScopesViewModel } from '../helpers'; + +const mapFlag = flag => ({ ...flag, scopes: mapToScopesViewModel(flag.scopes || []) }); + +const updateFlag = (state, flag) => { + const index = state[FEATURE_FLAG_SCOPE].findIndex(({ id }) => id === flag.id); + Vue.set(state[FEATURE_FLAG_SCOPE], index, flag); +}; + +const createPaginationInfo = (state, headers) => { + let paginationInfo; + if (Object.keys(headers).length) { + const normalizedHeaders = normalizeHeaders(headers); + paginationInfo = parseIntPagination(normalizedHeaders); + } else { + paginationInfo = headers; + } + return paginationInfo; +}; + +export default { + [types.SET_FEATURE_FLAGS_OPTIONS](state, options = {}) { + state.options = options; + }, + [types.REQUEST_FEATURE_FLAGS](state) { + state.isLoading = true; + }, + [types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) { + state.isLoading = false; + state.hasError = false; + state[FEATURE_FLAG_SCOPE] = (response.data.feature_flags || []).map(mapFlag); + + const paginationInfo = createPaginationInfo(state, response.headers); + state.count = { + ...state.count, + [FEATURE_FLAG_SCOPE]: paginationInfo?.total ?? state[FEATURE_FLAG_SCOPE].length, + }; + state.pageInfo = { + ...state.pageInfo, + [FEATURE_FLAG_SCOPE]: paginationInfo, + }; + }, + [types.RECEIVE_FEATURE_FLAGS_ERROR](state) { + state.isLoading = false; + state.hasError = true; + }, + [types.REQUEST_USER_LISTS](state) { + state.isLoading = true; + }, + [types.RECEIVE_USER_LISTS_SUCCESS](state, response) { + state.isLoading = false; + state.hasError = false; + state[USER_LIST_SCOPE] = response.data || []; + + const paginationInfo = createPaginationInfo(state, response.headers); + state.count = { + ...state.count, + [USER_LIST_SCOPE]: paginationInfo?.total ?? state[USER_LIST_SCOPE].length, + }; + state.pageInfo = { + ...state.pageInfo, + [USER_LIST_SCOPE]: paginationInfo, + }; + }, + [types.RECEIVE_USER_LISTS_ERROR](state) { + state.isLoading = false; + state.hasError = true; + }, + [types.REQUEST_ROTATE_INSTANCE_ID](state) { + state.isRotating = true; + state.hasRotateError = false; + }, + [types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS]( + state, + { + data: { token }, + }, + ) { + state.isRotating = false; + state.instanceId = token; + state.hasRotateError = false; + }, + [types.RECEIVE_ROTATE_INSTANCE_ID_ERROR](state) { + state.isRotating = false; + state.hasRotateError = true; + }, + [types.UPDATE_FEATURE_FLAG](state, flag) { + updateFlag(state, flag); + }, + [types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](state, data) { + updateFlag(state, mapFlag(data)); + }, + [types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, i) { + const flag = state[FEATURE_FLAG_SCOPE].find(({ id }) => i === id); + updateFlag(state, { ...flag, active: !flag.active }); + }, + [types.REQUEST_DELETE_USER_LIST](state, list) { + state.userLists = state.userLists.filter(l => l !== list); + }, + [types.RECEIVE_DELETE_USER_LIST_ERROR](state, { error, list }) { + state.isLoading = false; + state.hasError = false; + state.alerts = [].concat(error.message); + state.userLists = state.userLists.concat(list).sort((l1, l2) => l1.iid - l2.iid); + }, + [types.RECEIVE_CLEAR_ALERT](state, index) { + state.alerts.splice(index, 1); + }, +}; diff --git a/app/assets/javascripts/feature_flags/store/index/state.js b/app/assets/javascripts/feature_flags/store/index/state.js new file mode 100644 index 00000000000..f8439b02639 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/index/state.js @@ -0,0 +1,18 @@ +import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../constants'; + +export default ({ endpoint, projectId, unleashApiInstanceId, rotateInstanceIdPath }) => ({ + [FEATURE_FLAG_SCOPE]: [], + [USER_LIST_SCOPE]: [], + alerts: [], + count: {}, + pageInfo: { [FEATURE_FLAG_SCOPE]: {}, [USER_LIST_SCOPE]: {} }, + isLoading: true, + hasError: false, + endpoint, + rotateEndpoint: rotateInstanceIdPath, + instanceId: unleashApiInstanceId, + isRotating: false, + hasRotateError: false, + options: {}, + projectId, +}); diff --git a/app/assets/javascripts/feature_flags/store/new/actions.js b/app/assets/javascripts/feature_flags/store/new/actions.js new file mode 100644 index 00000000000..e21c128cd39 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/new/actions.js @@ -0,0 +1,37 @@ +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { NEW_VERSION_FLAG } from '../../constants'; +import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers'; + +/** + * Handles the creation of a new feature flag. + * + * Will dispatch `requestCreateFeatureFlag` + * Serializes the params and makes a post request + * Dispatches an action acording to the request status. + * + * @param {Object} params + */ +export const createFeatureFlag = ({ state, dispatch }, params) => { + dispatch('requestCreateFeatureFlag'); + + return axios + .post( + state.endpoint, + params.version === NEW_VERSION_FLAG + ? mapStrategiesToRails(params) + : mapFromScopesViewModel(params), + ) + .then(() => { + dispatch('receiveCreateFeatureFlagSuccess'); + visitUrl(state.path); + }) + .catch(error => dispatch('receiveCreateFeatureFlagError', error.response.data)); +}; + +export const requestCreateFeatureFlag = ({ commit }) => commit(types.REQUEST_CREATE_FEATURE_FLAG); +export const receiveCreateFeatureFlagSuccess = ({ commit }) => + commit(types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS); +export const receiveCreateFeatureFlagError = ({ commit }, error) => + commit(types.RECEIVE_CREATE_FEATURE_FLAG_ERROR, error); diff --git a/app/assets/javascripts/feature_flags/store/new/index.js b/app/assets/javascripts/feature_flags/store/new/index.js new file mode 100644 index 00000000000..f737e0517fc --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/new/index.js @@ -0,0 +1,11 @@ +import Vuex from 'vuex'; +import state from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default data => + new Vuex.Store({ + actions, + mutations, + state: state(data), + }); diff --git a/app/assets/javascripts/feature_flags/store/new/mutation_types.js b/app/assets/javascripts/feature_flags/store/new/mutation_types.js new file mode 100644 index 00000000000..71cc57c8d2e --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/new/mutation_types.js @@ -0,0 +1,3 @@ +export const REQUEST_CREATE_FEATURE_FLAG = 'REQUEST_CREATE_FEATURE_FLAG'; +export const RECEIVE_CREATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_CREATE_FEATURE_FLAG_SUCCESS'; +export const RECEIVE_CREATE_FEATURE_FLAG_ERROR = 'RECEIVE_CREATE_FEATURE_FLAG_ERROR'; diff --git a/app/assets/javascripts/feature_flags/store/new/mutations.js b/app/assets/javascripts/feature_flags/store/new/mutations.js new file mode 100644 index 00000000000..eeefc8413e8 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/new/mutations.js @@ -0,0 +1,15 @@ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_CREATE_FEATURE_FLAG](state) { + state.isSendingRequest = true; + state.error = []; + }, + [types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS](state) { + state.isSendingRequest = false; + }, + [types.RECEIVE_CREATE_FEATURE_FLAG_ERROR](state, error) { + state.isSendingRequest = false; + state.error = error.message || []; + }, +}; diff --git a/app/assets/javascripts/feature_flags/store/new/state.js b/app/assets/javascripts/feature_flags/store/new/state.js new file mode 100644 index 00000000000..56940925aa0 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/new/state.js @@ -0,0 +1,6 @@ +export default ({ endpoint, path }) => ({ + endpoint, + path, + isSendingRequest: false, + error: [], +}); diff --git a/app/assets/javascripts/feature_flags/utils.js b/app/assets/javascripts/feature_flags/utils.js new file mode 100644 index 00000000000..24c570657e6 --- /dev/null +++ b/app/assets/javascripts/feature_flags/utils.js @@ -0,0 +1,66 @@ +import { s__, n__, sprintf } from '~/locale'; +import { + ALL_ENVIRONMENTS_NAME, + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, +} from './constants'; + +const badgeTextByType = { + [ROLLOUT_STRATEGY_ALL_USERS]: { + name: s__('FeatureFlags|All Users'), + parameters: null, + }, + [ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT]: { + name: s__('FeatureFlags|Percent rollout'), + parameters: ({ parameters: { rollout, stickiness } }) => { + switch (stickiness) { + case 'USERID': + return sprintf(s__('FeatureFlags|%{percent} by user ID'), { percent: `${rollout}%` }); + case 'SESSIONID': + return sprintf(s__('FeatureFlags|%{percent} by session ID'), { percent: `${rollout}%` }); + case 'RANDOM': + return sprintf(s__('FeatureFlags|%{percent} randomly'), { percent: `${rollout}%` }); + default: + return sprintf(s__('FeatureFlags|%{percent} by available ID'), { + percent: `${rollout}%`, + }); + } + }, + }, + [ROLLOUT_STRATEGY_PERCENT_ROLLOUT]: { + name: s__('FeatureFlags|Percent of users'), + parameters: ({ parameters: { percentage } }) => `${percentage}%`, + }, + [ROLLOUT_STRATEGY_USER_ID]: { + name: s__('FeatureFlags|User IDs'), + parameters: ({ parameters: { userIds } }) => + sprintf(n__('FeatureFlags|%d user', 'FeatureFlags|%d users', userIds.split(',').length)), + }, + [ROLLOUT_STRATEGY_GITLAB_USER_LIST]: { + name: s__('FeatureFlags|User List'), + parameters: ({ user_list: { name } }) => name, + }, +}; + +const scopeName = ({ environment_scope: scope }) => + scope === ALL_ENVIRONMENTS_NAME ? s__('FeatureFlags|All Environments') : scope; + +export const labelForStrategy = strategy => { + const { name, parameters } = badgeTextByType[strategy.name]; + + if (parameters) { + return sprintf('%{name} - %{parameters}: %{scopes}', { + name, + parameters: parameters(strategy), + scopes: strategy.scopes.map(scopeName).join(', '), + }); + } + + return sprintf('%{name}: %{scopes}', { + name, + scopes: strategy.scopes.map(scopeName).join(', '), + }); +}; diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js index 80f78c154ee..51077296e20 100644 --- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -1,6 +1,6 @@ import { __ } from '~/locale'; -export default IssuableTokenKeys => { +export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { const draftToken = { token: { formattedKey: __('Draft'), @@ -51,16 +51,101 @@ export default IssuableTokenKeys => { IssuableTokenKeys.tokenKeysWithAlternative.push(draftToken.token); IssuableTokenKeys.conditions.push(...draftToken.conditions); - const targetBranchToken = { - formattedKey: __('Target-Branch'), - key: 'target-branch', - type: 'string', - param: '', - symbol: '', - icon: 'arrow-right', - tag: 'branch', + if (!disableTargetBranchFilter) { + const targetBranchToken = { + formattedKey: __('Target-Branch'), + key: 'target-branch', + type: 'string', + param: '', + symbol: '', + icon: 'arrow-right', + tag: 'branch', + }; + + IssuableTokenKeys.tokenKeys.push(targetBranchToken); + IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken); + } + + const approvedBy = { + token: { + formattedKey: __('Approved-By'), + key: 'approved-by', + type: 'array', + param: 'usernames[]', + symbol: '@', + icon: 'approval', + tag: '@approved-by', + }, + condition: [ + { + url: 'approved_by_usernames[]=None', + tokenKey: 'approved-by', + value: __('None'), + operator: '=', + }, + { + url: 'not[approved_by_usernames][]=None', + tokenKey: 'approved-by', + value: __('None'), + operator: '!=', + }, + { + url: 'approved_by_usernames[]=Any', + tokenKey: 'approved-by', + value: __('Any'), + operator: '=', + }, + { + url: 'not[approved_by_usernames][]=Any', + tokenKey: 'approved-by', + value: __('Any'), + operator: '!=', + }, + ], }; - IssuableTokenKeys.tokenKeys.push(targetBranchToken); - IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken); + const tokenPosition = 2; + IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, ...[approvedBy.token]); + IssuableTokenKeys.tokenKeysWithAlternative.splice(tokenPosition, 0, ...[approvedBy.token]); + IssuableTokenKeys.conditions.push(...approvedBy.condition); + + if (gon?.features?.deploymentFilters) { + const environmentToken = { + formattedKey: __('Environment'), + key: 'environment', + type: 'string', + param: '', + symbol: '', + icon: 'cloud-gear', + tag: 'environment', + }; + + const deployedBeforeToken = { + formattedKey: __('Deployed-before'), + key: 'deployed-before', + type: 'string', + param: '', + symbol: '', + icon: 'clock', + tag: 'deployed_before', + }; + + const deployedAfterToken = { + formattedKey: __('Deployed-after'), + key: 'deployed-after', + type: 'string', + param: '', + symbol: '', + icon: 'clock', + tag: 'deployed_after', + }; + + IssuableTokenKeys.tokenKeys.push(environmentToken, deployedBeforeToken, deployedAfterToken); + + IssuableTokenKeys.tokenKeysWithAlternative.push( + environmentToken, + deployedBeforeToken, + deployedAfterToken, + ); + } }; diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index 49bd3cda127..d7645f96406 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -15,6 +15,7 @@ export default class AvailableDropdownMappings { labelsEndpoint, milestonesEndpoint, releasesEndpoint, + environmentsEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups, @@ -24,6 +25,7 @@ export default class AvailableDropdownMappings { this.labelsEndpoint = labelsEndpoint; this.milestonesEndpoint = milestonesEndpoint; this.releasesEndpoint = releasesEndpoint; + this.environmentsEndpoint = environmentsEndpoint; this.groupsOnly = groupsOnly; this.includeAncestorGroups = includeAncestorGroups; this.includeDescendantGroups = includeDescendantGroups; @@ -69,6 +71,11 @@ export default class AvailableDropdownMappings { gl: DropdownUser, element: this.container.querySelector('#js-dropdown-assignee'), }, + 'approved-by': { + reference: null, + gl: DropdownUser, + element: this.container.querySelector('#js-dropdown-approved-by'), + }, milestone: { reference: null, gl: DropdownNonUser, @@ -144,6 +151,16 @@ export default class AvailableDropdownMappings { }, element: this.container.querySelector('#js-dropdown-target-branch'), }, + environment: { + reference: null, + gl: DropdownNonUser, + extraArguments: { + endpoint: this.getEnvironmentsEndpoint(), + symbol: '', + preprocessing: data => data.map(env => ({ title: env })), + }, + element: this.container.querySelector('#js-dropdown-environment'), + }, }; } @@ -189,6 +206,10 @@ export default class AvailableDropdownMappings { return mergeUrlParams(params, endpoint); } + getEnvironmentsEndpoint() { + return `${this.environmentsEndpoint}.json`; + } + getGroupId() { return this.filteredSearchInput.getAttribute('data-group-id') || ''; } diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index 0b9fe969da1..6cd6f9c9906 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -1,4 +1,4 @@ -export const USER_TOKEN_TYPES = ['author', 'assignee']; +export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by']; export const DROPDOWN_TYPE = { hint: 'hint', diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 161a65c511d..762383f5a1d 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -13,6 +13,7 @@ export default class FilteredSearchDropdownManager { labelsEndpoint = '', milestonesEndpoint = '', releasesEndpoint = '', + environmentsEndpoint = '', epicsEndpoint = '', tokenizer, page, @@ -29,6 +30,7 @@ export default class FilteredSearchDropdownManager { this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint); this.releasesEndpoint = removeTrailingSlash(releasesEndpoint); this.epicsEndpoint = removeTrailingSlash(epicsEndpoint); + this.environmentsEndpoint = removeTrailingSlash(environmentsEndpoint); this.tokenizer = tokenizer; this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys; this.filteredSearchInput = this.container.querySelector('.filtered-search'); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 3e4a9880134..261532f8867 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -110,6 +110,7 @@ export default class FilteredSearchManager { labelsEndpoint = '', milestonesEndpoint = '', releasesEndpoint = '', + environmentsEndpoint = '', epicsEndpoint = '', } = this.filteredSearchInput.dataset; @@ -118,6 +119,7 @@ export default class FilteredSearchManager { labelsEndpoint, milestonesEndpoint, releasesEndpoint, + environmentsEndpoint, epicsEndpoint, tokenizer: this.tokenizer, page: this.page, diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index f2dd8d5ace5..1d5f09a265b 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -1,5 +1,5 @@ -import * as Sentry from '@sentry/browser'; import { escape } from 'lodash'; +import * as Sentry from '~/sentry/wrapper'; import { spriteIcon } from './lib/utils/common_utils'; const FLASH_TYPES = { diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js index 1998bf4358a..5b03e1d19db 100644 --- a/app/assets/javascripts/frequent_items/index.js +++ b/app/assets/javascripts/frequent_items/index.js @@ -16,53 +16,63 @@ const frequentItemDropdowns = [ }, ]; +const initFrequentItemList = (namespace, key) => { + const el = document.getElementById(`js-${namespace}-dropdown`); + + // Don't do anything if element doesn't exist (No groups dropdown) + // This is for when the user accesses GitLab without logging in + if (!el) { + return; + } + + import('./components/app.vue') + .then(({ default: FrequentItems }) => { + // eslint-disable-next-line no-new + new Vue({ + el, + data() { + const { dataset } = this.$options.el; + const item = { + id: Number(dataset[`${key}Id`]), + name: dataset[`${key}Name`], + namespace: dataset[`${key}Namespace`], + webUrl: dataset[`${key}WebUrl`], + avatarUrl: dataset[`${key}AvatarUrl`] || null, + lastAccessedOn: Date.now(), + }; + + return { + currentUserName: dataset.userName, + currentItem: item, + }; + }, + render(createElement) { + return createElement(FrequentItems, { + props: { + namespace, + currentUserName: this.currentUserName, + currentItem: this.currentItem, + }, + }); + }, + }); + }) + .catch(() => {}); +}; + export default function initFrequentItemDropdowns() { frequentItemDropdowns.forEach(dropdown => { const { namespace, key } = dropdown; - const el = document.getElementById(`js-${namespace}-dropdown`); const navEl = document.getElementById(`nav-${namespace}-dropdown`); // Don't do anything if element doesn't exist (No groups dropdown) // This is for when the user accesses GitLab without logging in - if (!el || !navEl) { + if (!navEl) { return; } - import('./components/app.vue') - .then(({ default: FrequentItems }) => { - // eslint-disable-next-line no-new - new Vue({ - el, - data() { - const { dataset } = this.$options.el; - const item = { - id: Number(dataset[`${key}Id`]), - name: dataset[`${key}Name`], - namespace: dataset[`${key}Namespace`], - webUrl: dataset[`${key}WebUrl`], - avatarUrl: dataset[`${key}AvatarUrl`] || null, - lastAccessedOn: Date.now(), - }; - - return { - currentUserName: dataset.userName, - currentItem: item, - }; - }, - render(createElement) { - return createElement(FrequentItems, { - props: { - namespace, - currentUserName: this.currentUserName, - currentItem: this.currentItem, - }, - }); - }, - }); - }) - .catch(() => {}); - $(navEl).on('shown.bs.dropdown', () => { + initFrequentItemList(namespace, key); eventHub.$emit(`${namespace}-dropdownOpen`); }); }); diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js index 112e8eaaf17..954d426c86c 100644 --- a/app/assets/javascripts/frequent_items/utils.js +++ b/app/assets/javascripts/frequent_items/utils.js @@ -1,6 +1,6 @@ import { take } from 'lodash'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { sanitize } from 'dompurify'; +import { sanitize } from '~/lib/dompurify'; import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants'; export const isMobile = () => ['md', 'sm', 'xs'].includes(bp.getBreakpointSize()); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 409733c73b9..62948f74aaa 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -52,7 +52,6 @@ export const defaultAutocompleteConfig = { milestones: true, labels: true, snippets: true, - vulnerabilities: true, }; class GfmAutoComplete { @@ -179,6 +178,9 @@ class GfmAutoComplete { } setupEmoji($input) { + const self = this; + const { filter, ...defaults } = this.getDefaultCallbacks(); + // Emoji $input.atwho({ at: ':', @@ -189,18 +191,47 @@ class GfmAutoComplete { } return tmpl; }, - // eslint-disable-next-line no-template-curly-in-string - insertTpl: ':${name}:', + insertTpl: GfmAutoComplete.Emoji.insertTemplateFunction, skipSpecialCharacterTest: true, data: GfmAutoComplete.defaultLoadingData, callbacks: { - ...this.getDefaultCallbacks(), + ...defaults, matcher(flag, subtext) { const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi'); const match = regexp.exec(subtext); return match && match.length ? match[1] : null; }, + filter(query, items, searchKey) { + const filtered = filter.call(this, query, items, searchKey); + if (query.length === 0 || GfmAutoComplete.isLoading(items)) { + return filtered; + } + + // map from value to "<value> is <field> of <emoji>", arranged by emoji + const emojis = {}; + filtered.forEach(({ name: value }) => { + self.emojiLookup[value].forEach(({ emoji: { name }, kind }) => { + let entry = emojis[name]; + if (!entry) { + entry = {}; + emojis[name] = entry; + } + if (!(kind in entry) || value.localeCompare(entry[kind]) < 0) { + entry[kind] = value; + } + }); + }); + + // collate results to list, prefering name > unicode > alias > description + const results = []; + Object.values(emojis).forEach(({ name, unicode, alias, description }) => { + results.push(name || unicode || alias || description); + }); + + // return to the form atwho wants + return results.map(name => ({ name })); + }, }, }); } @@ -593,12 +624,7 @@ class GfmAutoComplete { if (this.cachedData[at]) { this.loadData($input, at, this.cachedData[at]); } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { - Emoji.initEmojiMap() - .then(() => { - this.loadData($input, at, Emoji.getValidEmojiNames()); - GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag; - }) - .catch(() => {}); + this.loadEmojiData($input, at).catch(() => {}); } else if (dataSource) { AjaxCache.retrieve(dataSource, true) .then(data => { @@ -621,6 +647,39 @@ class GfmAutoComplete { return $input.trigger('keyup'); } + async loadEmojiData($input, at) { + await Emoji.initEmojiMap(); + + // All the emoji + const emojis = Emoji.getAllEmoji(); + + // Add all of the fields to atwho's database + this.loadData($input, at, [ + ...Object.keys(emojis), // Names + ...Object.values(emojis).flatMap(({ aliases }) => aliases), // Aliases + ...Object.values(emojis).map(({ e }) => e), // Unicode values + ...Object.values(emojis).map(({ d }) => d), // Descriptions + ]); + + // Construct a lookup that can correlate a value to "<value> is the <field> of <emoji>" + const lookup = {}; + const add = (key, kind, emoji) => { + if (!(key in lookup)) { + lookup[key] = []; + } + lookup[key].push({ kind, emoji }); + }; + Object.values(emojis).forEach(emoji => { + add(emoji.name, 'name', emoji); + add(emoji.d, 'description', emoji); + add(emoji.e, 'unicode', emoji); + emoji.aliases.forEach(a => add(a, 'alias', emoji)); + }); + this.emojiLookup = lookup; + + GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag; + } + clearCache() { this.cachedData = {}; } @@ -648,8 +707,7 @@ class GfmAutoComplete { // https://github.com/ichord/At.js const atSymbolsWithBar = Object.keys(controllers) .join('|') - .replace(/[$]/, '\\$&') - .replace(/[+]/, '\\+'); + .replace(/[$]/, '\\$&'); const atSymbolsWithoutBar = Object.keys(controllers).join(''); const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop(); const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); @@ -680,19 +738,39 @@ GfmAutoComplete.atTypeMap = { '~': 'labels', '%': 'milestones', '/': 'commands', - '+': 'vulnerabilities', $: 'snippets', }; +function findEmoji(name) { + return Emoji.searchEmoji(name, { match: 'contains', raw: true }).sort((a, b) => { + if (a.index !== b.index) { + return a.index - b.index; + } + return a.field.localeCompare(b.field); + }); +} + // Emoji GfmAutoComplete.glEmojiTag = null; GfmAutoComplete.Emoji = { + insertTemplateFunction(value) { + const results = findEmoji(value.name); + if (results.length) { + return `:${results[0].emoji.name}:`; + } + return `:${value.name}:`; + }, templateFunction(name) { // glEmojiTag helper is loaded on-demand in fetchData() - if (GfmAutoComplete.glEmojiTag) { + if (!GfmAutoComplete.glEmojiTag) return `<li>${name}</li>`; + + const results = findEmoji(name); + if (!results.length) { return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`; } - return `<li>${name}</li>`; + + const { field, emoji } = results[0]; + return `<li>${field} ${GfmAutoComplete.glEmojiTag(emoji.name)}</li>`; }, }; // Team Members diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 0a1e5490237..6958cf4c173 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -6,7 +6,14 @@ import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_ import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; export default class GLForm { - constructor(form, enableGFM = {}) { + /** + * Create a GLForm + * + * @param {jQuery} form Root element of the GLForm + * @param {Object} enableGFM Which autocomplete features should be enabled? + * @param {Boolean} forceNew If true, treat the element as a **new** form even if `gfm-form` class already exists. + */ + constructor(form, enableGFM = {}, forceNew = false) { this.form = form; this.textarea = this.form.find('textarea.js-gfm-input'); this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM }; @@ -22,7 +29,7 @@ export default class GLForm { // Before we start, we should clean up any previous data for this form this.destroy(); // Set up the form - this.setupForm(); + this.setupForm(forceNew); this.form.data('glForm', this); } @@ -39,8 +46,8 @@ export default class GLForm { this.form.data('glForm', null); } - setupForm() { - const isNewForm = this.form.is(':not(.gfm-form)'); + setupForm(forceNew = false) { + const isNewForm = this.form.is(':not(.gfm-form)') || forceNew; this.form.removeClass('js-new-note-form'); if (isNewForm) { this.form.find('.div-dropzone').remove(); diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue index 79494cb173b..7a991ac2455 100644 --- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue +++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue @@ -92,11 +92,9 @@ export default { </a> </p> </gl-form-group> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button variant="success" category="primary" @click="updateGrafanaIntegration"> - {{ __('Save Changes') }} - </gl-button> - </div> + <gl-button variant="success" category="primary" @click="updateGrafanaIntegration"> + {{ __('Save Changes') }} + </gl-button> </form> </div> </section> diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/group_label_subscription.js index 8c6c0714ee8..bfaa54080bd 100644 --- a/app/assets/javascripts/group_label_subscription.js +++ b/app/assets/javascripts/group_label_subscription.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from './flash'; +import { fixTitle, hide } from '~/tooltips'; const tooltipTitles = { group: __('Unsubscribe at group level'), @@ -59,9 +60,9 @@ export default class GroupLabelSubscription { const type = $button.hasClass('js-group-level') ? 'group' : 'project'; const newTitle = tooltipTitles[type]; - $('.js-unsubscribe-button', $button.closest('.label-actions-list')) - .tooltip('hide') - .attr('title', newTitle) - .tooltip('_fixTitle'); + const $el = $('.js-unsubscribe-button', $button.closest('.label-actions-list')); + hide($el); + $el.attr('title', `${newTitle}`); + fixTitle($el); } } diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue new file mode 100644 index 00000000000..e396521ce7c --- /dev/null +++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue @@ -0,0 +1,139 @@ +<script> +import { GlToggle, GlLoadingIcon, GlTooltip, GlAlert } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import { + DEBOUNCE_TOGGLE_DELAY, + ERROR_MESSAGE, + ENABLED, + DISABLED, + ALLOW_OVERRIDE, +} from '../constants'; + +export default { + components: { + GlToggle, + GlLoadingIcon, + GlTooltip, + GlAlert, + }, + props: { + updatePath: { + type: String, + required: true, + }, + sharedRunnersAvailability: { + type: String, + required: true, + }, + parentSharedRunnersAvailability: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isLoading: false, + enabled: true, + allowOverride: false, + error: null, + }; + }, + computed: { + toggleDisabled() { + return this.parentSharedRunnersAvailability === DISABLED || this.isLoading; + }, + enabledOrDisabledSetting() { + return this.enabled ? ENABLED : DISABLED; + }, + disabledWithOverrideSetting() { + return this.allowOverride ? ALLOW_OVERRIDE : DISABLED; + }, + }, + created() { + if (this.sharedRunnersAvailability !== ENABLED) { + this.enabled = false; + } + + if (this.sharedRunnersAvailability === ALLOW_OVERRIDE) { + this.allowOverride = true; + } + }, + methods: { + generatePayload(data) { + return { shared_runners_setting: data }; + }, + enableOrDisable() { + this.updateRunnerSettings(this.generatePayload(this.enabledOrDisabledSetting)); + + // reset override toggle to false if shared runners are enabled + this.allowOverride = false; + }, + override() { + this.updateRunnerSettings(this.generatePayload(this.disabledWithOverrideSetting)); + }, + updateRunnerSettings: debounce(function debouncedUpdateRunnerSettings(setting) { + this.isLoading = true; + + axios + .put(this.updatePath, setting) + .then(() => { + this.isLoading = false; + }) + .catch(error => { + const message = [ + error.response?.data?.error || __('An error occurred while updating configuration.'), + ERROR_MESSAGE, + ].join(' '); + + this.error = message; + }); + }, DEBOUNCE_TOGGLE_DELAY), + }, +}; +</script> + +<template> + <div ref="sharedRunnersForm"> + <gl-alert v-if="error" variant="danger" :dismissible="false">{{ error }}</gl-alert> + + <h4 class="gl-display-flex gl-align-items-center"> + {{ __('Set up shared runner availability') }} + <gl-loading-icon v-if="isLoading" class="gl-ml-3" inline /> + </h4> + + <section class="gl-mt-5"> + <gl-toggle + v-model="enabled" + :disabled="toggleDisabled" + :label="__('Enable shared runners for this group')" + data-testid="enable-runners-toggle" + @change="enableOrDisable" + /> + + <span class="gl-text-gray-600"> + {{ __('Enable shared runners for all projects and subgroups in this group.') }} + </span> + </section> + + <section v-if="!enabled" class="gl-mt-5"> + <gl-toggle + v-model="allowOverride" + :disabled="toggleDisabled" + :label="__('Allow projects and subgroups to override the group setting')" + data-testid="override-runners-toggle" + @change="override" + /> + + <span class="gl-text-gray-600"> + {{ __('Allows projects or subgroups in this group to override the global setting.') }} + </span> + </section> + + <gl-tooltip v-if="toggleDisabled" :target="() => $refs.sharedRunnersForm"> + {{ __('Shared runners are disabled for the parent group') }} + </gl-tooltip> + </div> +</template> diff --git a/app/assets/javascripts/group_settings/constants.js b/app/assets/javascripts/group_settings/constants.js new file mode 100644 index 00000000000..c7bb851c06b --- /dev/null +++ b/app/assets/javascripts/group_settings/constants.js @@ -0,0 +1,11 @@ +import { __ } from '~/locale'; + +// Debounce delay in milliseconds +export const DEBOUNCE_TOGGLE_DELAY = 1000; + +export const ERROR_MESSAGE = __('Refresh the page and try again.'); + +// runner setting options +export const ENABLED = 'enabled'; +export const DISABLED = 'disabled_and_unoverridable'; +export const ALLOW_OVERRIDE = 'disabled_with_override'; diff --git a/app/assets/javascripts/group_settings/mount_shared_runners.js b/app/assets/javascripts/group_settings/mount_shared_runners.js new file mode 100644 index 00000000000..44284204c41 --- /dev/null +++ b/app/assets/javascripts/group_settings/mount_shared_runners.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import UpdateSharedRunnersForm from './components/shared_runners_form.vue'; + +export default (containerId = 'update-shared-runners-form') => { + const containerEl = document.getElementById(containerId); + + return new Vue({ + el: containerEl, + render(createElement) { + return createElement(UpdateSharedRunnersForm, { + props: containerEl.dataset, + }); + }, + }); +}; diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue index 8c7192b49a0..d2a613bed4f 100644 --- a/app/assets/javascripts/groups/components/group_folder.vue +++ b/app/assets/javascripts/groups/components/group_folder.vue @@ -1,8 +1,12 @@ <script> +import { GlIcon } from '@gitlab/ui'; import { n__ } from '../../locale'; import { MAX_CHILDREN_COUNT } from '../constants'; export default { + components: { + GlIcon, + }, props: { parentGroup: { type: Object, @@ -45,7 +49,7 @@ export default { /> <li v-if="hasMoreChildren" class="group-row"> <a :href="parentGroup.relativePath" class="group-row-contents has-more-items py-2"> - <i class="fa fa-external-link" aria-hidden="true"> </i> {{ moreChildrenStats }} + <gl-icon name="external-link" aria-hidden="true" /> {{ moreChildrenStats }} </a> </li> </ul> diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue index 5487e25066e..2e92a608f76 100644 --- a/app/assets/javascripts/groups/components/item_actions.vue +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -53,6 +53,7 @@ export default { :aria-label="leaveBtnTitle" data-container="body" data-placement="bottom" + data-testid="leave-group-btn" class="leave-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5" @click.prevent="onLeaveGroup" > @@ -66,6 +67,7 @@ export default { :aria-label="editBtnTitle" data-container="body" data-placement="bottom" + data-testid="edit-group-btn" class="edit-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5" > <gl-icon name="settings" class="position-top-0 align-middle" /> diff --git a/app/assets/javascripts/groups/components/item_stats_value.vue b/app/assets/javascripts/groups/components/item_stats_value.vue index 18efd8c6823..52dc7cfa19d 100644 --- a/app/assets/javascripts/groups/components/item_stats_value.vue +++ b/app/assets/javascripts/groups/components/item_stats_value.vue @@ -1,13 +1,12 @@ <script> -import { GlIcon } from '@gitlab/ui'; -import tooltip from '~/vue_shared/directives/tooltip'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; export default { components: { GlIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { title: { @@ -51,12 +50,13 @@ export default { <template> <span - v-tooltip + v-gl-tooltip :data-placement="tooltipPlacement" :class="cssClass" :title="title" data-container="body" > - <gl-icon :name="iconName" /> <span v-if="isValuePresent" class="stat-value"> {{ value }} </span> + <gl-icon :name="iconName" /> + <span v-if="isValuePresent" class="stat-value" data-testid="itemStatValue"> {{ value }} </span> </span> </template> diff --git a/app/assets/javascripts/groups/members/components/app.vue b/app/assets/javascripts/groups/members/components/app.vue index e94b28f5773..2e6dd4a0bad 100644 --- a/app/assets/javascripts/groups/members/components/app.vue +++ b/app/assets/javascripts/groups/members/components/app.vue @@ -1,11 +1,38 @@ <script> +import { mapState, mapMutations } from 'vuex'; +import { GlAlert } from '@gitlab/ui'; +import MembersTable from '~/vue_shared/components/members/table/members_table.vue'; +import { scrollToElement } from '~/lib/utils/common_utils'; +import { HIDE_ERROR } from '~/vuex_shared/modules/members/mutation_types'; + export default { name: 'GroupMembersApp', + components: { MembersTable, GlAlert }, + computed: { + ...mapState(['showError', 'errorMessage']), + }, + watch: { + showError(value) { + if (value) { + this.$nextTick(() => { + scrollToElement(this.$refs.errorAlert.$el); + }); + } + }, + }, + methods: { + ...mapMutations({ + hideError: HIDE_ERROR, + }), + }, }; </script> <template> - <span> - <!-- Temporary empty template --> - </span> + <div> + <gl-alert v-if="showError" ref="errorAlert" variant="danger" @dismiss="hideError">{{ + errorMessage + }}</gl-alert> + <members-table /> + </div> </template> diff --git a/app/assets/javascripts/groups/members/constants.js b/app/assets/javascripts/groups/members/constants.js new file mode 100644 index 00000000000..6d71b666d7a --- /dev/null +++ b/app/assets/javascripts/groups/members/constants.js @@ -0,0 +1,5 @@ +export const GROUP_MEMBER_BASE_PROPERTY_NAME = 'group_member'; +export const GROUP_MEMBER_ACCESS_LEVEL_PROPERTY_NAME = 'access_level'; + +export const GROUP_LINK_BASE_PROPERTY_NAME = 'group_link'; +export const GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME = 'group_access'; diff --git a/app/assets/javascripts/groups/members/index.js b/app/assets/javascripts/groups/members/index.js index 4ca1756f10c..3bbef14d199 100644 --- a/app/assets/javascripts/groups/members/index.js +++ b/app/assets/javascripts/groups/members/index.js @@ -1,23 +1,24 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import { GlToast } from '@gitlab/ui'; +import { parseDataAttributes } from 'ee_else_ce/groups/members/utils'; import App from './components/app.vue'; import membersModule from '~/vuex_shared/modules/members'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -export default el => { +export const initGroupMembersApp = (el, tableFields, requestFormatter) => { if (!el) { return () => {}; } Vue.use(Vuex); - - const { members, groupId } = el.dataset; + Vue.use(GlToast); const store = new Vuex.Store({ ...membersModule({ - members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }), - sourceId: parseInt(groupId, 10), + ...parseDataAttributes(el), currentUserId: gon.current_user_id || null, + tableFields, + requestFormatter, }), }); diff --git a/app/assets/javascripts/groups/members/utils.js b/app/assets/javascripts/groups/members/utils.js new file mode 100644 index 00000000000..662eecc4e38 --- /dev/null +++ b/app/assets/javascripts/groups/members/utils.js @@ -0,0 +1,44 @@ +import { isUndefined } from 'lodash'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { + GROUP_MEMBER_BASE_PROPERTY_NAME, + GROUP_MEMBER_ACCESS_LEVEL_PROPERTY_NAME, + GROUP_LINK_BASE_PROPERTY_NAME, + GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME, +} from './constants'; + +export const parseDataAttributes = el => { + const { members, groupId, memberPath } = el.dataset; + + return { + members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }), + sourceId: parseInt(groupId, 10), + memberPath, + }; +}; + +const baseRequestFormatter = (basePropertyName, accessLevelPropertyName) => ({ + accessLevel, + ...otherProperties +}) => { + const accessLevelProperty = !isUndefined(accessLevel) + ? { [accessLevelPropertyName]: accessLevel } + : {}; + + return { + [basePropertyName]: { + ...accessLevelProperty, + ...otherProperties, + }, + }; +}; + +export const memberRequestFormatter = baseRequestFormatter( + GROUP_MEMBER_BASE_PROPERTY_NAME, + GROUP_MEMBER_ACCESS_LEVEL_PROPERTY_NAME, +); + +export const groupLinkRequestFormatter = baseRequestFormatter( + GROUP_LINK_BASE_PROPERTY_NAME, + GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME, +); diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index 183816921c1..644808cb83a 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -1,8 +1,6 @@ <script> -import $ from 'jquery'; import { mapActions, mapState } from 'vuex'; -import { GlIcon } from '@gitlab/ui'; -import tooltip from '~/vue_shared/directives/tooltip'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { leftSidebarViews } from '../constants'; export default { @@ -10,7 +8,7 @@ export default { GlIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, computed: { ...mapState(['currentActivityView']), @@ -22,9 +20,7 @@ export default { this.updateActivityBarView(view); - // TODO: We must use JQuery here to interact with the Bootstrap tooltip API - // https://gitlab.com/gitlab-org/gitlab/-/issues/217577 - $(e.currentTarget).tooltip('hide'); + this.$root.$emit('bv::hide::tooltip'); }, }, leftSidebarViews, @@ -32,11 +28,11 @@ export default { </script> <template> - <nav class="ide-activity-bar"> + <nav class="ide-activity-bar" data-testid="left-sidebar"> <ul class="list-unstyled"> <li> <button - v-tooltip + v-gl-tooltip.right.viewport :class="{ active: currentActivityView === $options.leftSidebarViews.edit.name, }" @@ -54,7 +50,7 @@ export default { </li> <li> <button - v-tooltip + v-gl-tooltip.right.viewport :class="{ active: currentActivityView === $options.leftSidebarViews.review.name, }" @@ -71,7 +67,7 @@ export default { </li> <li> <button - v-tooltip + v-gl-tooltip.right.viewport :class="{ active: currentActivityView === $options.leftSidebarViews.commit.name, }" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index de4b0a34002..b89329c92ec 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -1,8 +1,8 @@ <script> -/* eslint-disable vue/no-v-html */ import { escape } from 'lodash'; import { mapState, mapGetters, createNamespacedHelpers } from 'vuex'; -import { sprintf, s__ } from '~/locale'; +import { GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; import consts from '../../stores/modules/commit/constants'; import RadioGroup from './radio_group.vue'; import NewMergeRequestOption from './new_merge_request_option.vue'; @@ -13,6 +13,7 @@ const { mapState: mapCommitState, mapActions: mapCommitActions } = createNamespa export default { components: { + GlSprintf, RadioGroup, NewMergeRequestOption, }, @@ -20,12 +21,8 @@ export default { ...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']), ...mapCommitState(['commitAction']), ...mapGetters(['currentBranch', 'emptyRepo', 'canPushToBranch']), - commitToCurrentBranchText() { - return sprintf( - s__('IDE|Commit to %{branchName} branch'), - { branchName: `<strong class="monospace">${escape(this.currentBranchId)}</strong>` }, - false, - ); + currentBranchText() { + return escape(this.currentBranchId); }, containsStagedChanges() { return this.changedFiles.length > 0 && this.stagedFiles.length > 0; @@ -77,11 +74,13 @@ export default { :disabled="!canPushToBranch" :title="$options.currentBranchPermissionsTooltip" > - <span - class="ide-option-label" - data-qa-selector="commit_to_current_branch_radio" - v-html="commitToCurrentBranchText" - ></span> + <span class="ide-option-label" data-qa-selector="commit_to_current_branch_radio"> + <gl-sprintf :message="s__('IDE|Commit to %{branchName} branch')"> + <template #branchName> + <strong class="monospace">{{ currentBranchText }}</strong> + </template> + </gl-sprintf> + </span> </radio-group> <template v-if="!emptyRepo"> <radio-group diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue index bbcb866c758..53fac09ab66 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -1,6 +1,6 @@ <script> import { mapActions } from 'vuex'; -import { GlModal } from '@gitlab/ui'; +import { GlModal, GlButton } from '@gitlab/ui'; import { sprintf, __ } from '~/locale'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; @@ -8,6 +8,7 @@ import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; export default { components: { GlModal, + GlButton, FileIcon, ChangedFileIcon, }, @@ -52,15 +53,16 @@ export default { </strong> <changed-file-icon :file="activeFile" :is-centered="false" /> <div class="ml-auto"> - <button + <gl-button v-if="canDiscard" ref="discardButton" - type="button" - class="btn btn-remove btn-inverted gl-mr-3" + category="secondary" + variant="danger" + class="gl-mr-3" @click="showDiscardModal" > {{ __('Discard changes') }} - </button> + </gl-button> </div> <gl-modal ref="discardModal" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 73c56514fce..f36fe87ccfa 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -7,7 +7,6 @@ import CommitMessageField from './message_field.vue'; import Actions from './actions.vue'; import SuccessMessage from './success_message.vue'; import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants'; -import consts from '../../stores/modules/commit/constants'; import { createUnexpectedCommitError } from '../../lib/errors'; export default { @@ -45,12 +44,11 @@ export default { return this.currentActivityView === leftSidebarViews.commit.name; }, commitErrorPrimaryAction() { - if (!this.lastCommitError?.canCreateBranch) { - return undefined; - } + const { primaryAction } = this.lastCommitError || {}; return { - text: __('Create new branch'), + button: primaryAction ? { text: primaryAction.text } : undefined, + callback: primaryAction?.callback?.bind(this, this.$store) || (() => {}), }; }, }, @@ -78,9 +76,6 @@ export default { commit() { return this.commitChanges(); }, - forceCreateNewBranch() { - return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commit()); - }, handleCompactState() { if (this.lastCommitMsg) { this.isCompact = false; @@ -188,9 +183,9 @@ export default { ref="commitErrorModal" modal-id="ide-commit-error-modal" :title="lastCommitError.title" - :action-primary="commitErrorPrimaryAction" + :action-primary="commitErrorPrimaryAction.button" :action-cancel="{ text: __('Cancel') }" - @ok="forceCreateNewBranch" + @ok="commitErrorPrimaryAction.callback" > <div v-safe-html="lastCommitError.messageHTML"></div> </gl-modal> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index 2787b10a48b..7d08815b033 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlPopover } from '@gitlab/ui'; import { __, sprintf } from '../../../locale'; import popover from '../../../vue_shared/directives/popover'; import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants'; @@ -10,6 +10,7 @@ export default { }, components: { GlIcon, + GlPopover, }, props: { text: { @@ -58,7 +59,7 @@ export default { }, }, popoverOptions: { - trigger: 'hover', + triggers: 'hover', placement: 'top', content: sprintf( __(` @@ -83,9 +84,16 @@ export default { <ul class="nav-links"> <li> {{ __('Commit Message') }} - <span v-popover="$options.popoverOptions" class="form-text text-muted gl-ml-3"> - <gl-icon name="question" /> - </span> + <div id="ide-commit-message-popover-container"> + <span id="ide-commit-message-question" class="form-text text-muted gl-ml-3"> + <gl-icon name="question" /> + </span> + <gl-popover + target="ide-commit-message-question" + container="ide-commit-message-popover-container" + v-bind="$options.popoverOptions" + /> + </div> </li> </ul> </div> @@ -108,6 +116,7 @@ export default { :placeholder="placeholder" :value="text" class="note-textarea ide-commit-message-textarea" + data-qa-selector="ide_commit_message_field" dir="auto" name="commit-message" @scroll="handleScroll" diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue index 732fa0786b0..dec8aa61838 100644 --- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -1,8 +1,12 @@ <script> +import { GlButton } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import { viewerTypes } from '../constants'; export default { + components: { + GlButton, + }, props: { viewer: { type: String, @@ -31,7 +35,7 @@ export default { <template> <div class="dropdown"> - <button type="button" class="btn btn-link" data-toggle="dropdown">{{ __('Edit') }}</button> + <gl-button variant="link" data-toggle="dropdown">{{ __('Edit') }}</gl-button> <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left"> <ul> <li> diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue index b6a57d1b6e6..88dca2f0556 100644 --- a/app/assets/javascripts/ide/components/file_templates/bar.vue +++ b/app/assets/javascripts/ide/components/file_templates/bar.vue @@ -1,10 +1,12 @@ <script> +import { GlButton } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import Dropdown from './dropdown.vue'; export default { components: { Dropdown, + GlButton, }, computed: { ...mapGetters(['activeFile']), @@ -65,9 +67,9 @@ export default { @click="selectTemplate" /> <transition name="fade"> - <button v-show="updateSuccess" type="button" class="btn btn-default" @click="undo"> + <gl-button v-show="updateSuccess" category="secondary" variant="default" @click="undo"> {{ __('Undo') }} - </button> + </gl-button> </transition> </div> </template> diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue index d80662f6ae1..cfd2555b769 100644 --- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue +++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue @@ -1,12 +1,13 @@ <script> import $ from 'jquery'; import { mapActions, mapState } from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; export default { components: { DropdownButton, + GlIcon, GlLoadingIcon, }, props: { @@ -85,7 +86,7 @@ export default { type="search" class="dropdown-input-field qa-dropdown-filter-input" /> - <i aria-hidden="true" class="fa fa-search dropdown-input-search"></i> + <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" /> </div> <div class="dropdown-content"> <gl-loading-icon v-if="showLoading" size="lg" /> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 1b03d9eee8b..8f23856fd6c 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -2,7 +2,18 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; +import { + WEBIDE_MARK_APP_START, + WEBIDE_MARK_FILE_FINISH, + WEBIDE_MARK_FILE_CLICKED, + WEBIDE_MARK_TREE_FINISH, + WEBIDE_MEASURE_TREE_FROM_REQUEST, + WEBIDE_MEASURE_FILE_FROM_REQUEST, + WEBIDE_MEASURE_FILE_AFTER_INTERACTION, +} from '~/performance_constants'; +import { performanceMarkAndMeasure } from '~/performance_utils'; import { modalTypes } from '../constants'; +import eventHub from '../eventhub'; import FindFile from '~/vue_shared/components/file_finder/index.vue'; import NewModal from './new_dropdown/modal.vue'; import IdeSidebar from './ide_side_bar.vue'; @@ -14,6 +25,22 @@ import ErrorMessage from './error_message.vue'; import CommitEditorHeader from './commit_sidebar/editor_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { measurePerformance } from '../utils'; + +eventHub.$on(WEBIDE_MEASURE_TREE_FROM_REQUEST, () => + measurePerformance(WEBIDE_MARK_TREE_FINISH, WEBIDE_MEASURE_TREE_FROM_REQUEST), +); +eventHub.$on(WEBIDE_MEASURE_FILE_FROM_REQUEST, () => + measurePerformance(WEBIDE_MARK_FILE_FINISH, WEBIDE_MEASURE_FILE_FROM_REQUEST), +); +eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () => + measurePerformance( + WEBIDE_MARK_FILE_FINISH, + WEBIDE_MEASURE_FILE_AFTER_INTERACTION, + WEBIDE_MARK_FILE_CLICKED, + ), +); + export default { components: { NewModal, @@ -59,6 +86,9 @@ export default { if (this.themeName) document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`); }, + beforeCreate() { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_APP_START }); + }, methods: { ...mapActions(['toggleFileFinder']), onBeforeUnload(e = {}) { diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue index e36d0a5a5b1..7d2f0acb08c 100644 --- a/app/assets/javascripts/ide/components/ide_review.vue +++ b/app/assets/javascripts/ide/components/ide_review.vue @@ -23,26 +23,32 @@ export default { }, }, mounted() { - if (this.activeFile && this.activeFile.pending && !this.activeFile.deleted) { - this.$router.push(this.getUrlForPath(this.activeFile.path), () => { - this.updateViewer('editor'); - }); - } else if (this.activeFile && this.activeFile.deleted) { - this.resetOpenFiles(); - } - - this.$nextTick(() => { - this.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff); - }); + this.initialize(); + }, + activated() { + this.initialize(); }, methods: { ...mapActions(['updateViewer', 'resetOpenFiles']), + initialize() { + if (this.activeFile && this.activeFile.pending && !this.activeFile.deleted) { + this.$router.push(this.getUrlForPath(this.activeFile.path), () => { + this.updateViewer(viewerTypes.edit); + }); + } else if (this.activeFile && this.activeFile.deleted) { + this.resetOpenFiles(); + } + + this.$nextTick(() => { + this.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff); + }); + }, }, }; </script> <template> - <ide-tree-list :viewer-type="viewer" header-class="ide-review-header"> + <ide-tree-list header-class="ide-review-header"> <template #header> <div class="ide-review-button-holder"> {{ __('Review') }} diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index ed68ca5cae9..53dfc133fc8 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -7,9 +7,8 @@ import ActivityBar from './activity_bar.vue'; import RepoCommitSection from './repo_commit_section.vue'; import CommitForm from './commit_sidebar/form.vue'; import IdeReview from './ide_review.vue'; -import SuccessMessage from './commit_sidebar/success_message.vue'; import IdeProjectHeader from './ide_project_header.vue'; -import { leftSidebarViews, SIDEBAR_INIT_WIDTH } from '../constants'; +import { SIDEBAR_INIT_WIDTH } from '../constants'; export default { components: { @@ -20,18 +19,11 @@ export default { IdeTree, CommitForm, IdeReview, - SuccessMessage, IdeProjectHeader, }, computed: { ...mapState(['loading', 'currentActivityView', 'changedFiles', 'stagedFiles', 'lastCommitMsg']), ...mapGetters(['currentProject', 'someUncommittedChanges']), - showSuccessMessage() { - return ( - this.currentActivityView === leftSidebarViews.edit.name && - (this.lastCommitMsg && !this.someUncommittedChanges) - ); - }, }, SIDEBAR_INIT_WIDTH, }; @@ -44,7 +36,7 @@ export default { class="multi-file-commit-panel flex-column" > <template v-if="loading"> - <div class="multi-file-commit-panel-inner"> + <div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner"> <div v-for="n in 3" :key="n" class="multi-file-loading-container"> <gl-skeleton-loading /> </div> @@ -54,9 +46,11 @@ export default { <ide-project-header :project="currentProject" /> <div class="ide-context-body d-flex flex-fill"> <activity-bar /> - <div class="multi-file-commit-panel-inner"> + <div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner"> <div class="multi-file-commit-panel-inner-content"> - <component :is="currentActivityView" /> + <keep-alive> + <component :is="currentActivityView" /> + </keep-alive> </div> <commit-form /> </div> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 146e818d654..ee292190e06 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -1,10 +1,9 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ import { mapActions, mapState, mapGetters } from 'vuex'; -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import IdeStatusList from './ide_status_list.vue'; import IdeStatusMr from './ide_status_mr.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; import timeAgoMixin from '~/vue_shared/mixins/timeago'; import CiIcon from '../../vue_shared/components/ci_icon.vue'; import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; @@ -19,7 +18,7 @@ export default { IdeStatusMr, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, mixins: [timeAgoMixin], data() { @@ -85,7 +84,7 @@ export default { @click="openRightPane($options.rightSidebarViews.pipelines)" > <ci-icon - v-tooltip + v-gl-tooltip :status="latestPipeline.details.status" :title="latestPipeline.details.status.text" /> @@ -99,7 +98,7 @@ export default { <gl-icon name="commit" /> <a - v-tooltip + v-gl-tooltip :title="lastCommit.message" :href="getCommitPath(lastCommit.short_id)" class="commit-sha" @@ -116,7 +115,7 @@ export default { /> {{ lastCommit.author_name }} <time - v-tooltip + v-gl-tooltip :datetime="lastCommit.committed_date" :title="tooltipTitle(lastCommit.committed_date)" data-placement="top" diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue index 747d5044790..51d783df0ad 100644 --- a/app/assets/javascripts/ide/components/ide_tree.vue +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import { modalTypes } from '../constants'; +import { modalTypes, viewerTypes } from '../constants'; import IdeTreeList from './ide_tree_list.vue'; import Upload from './new_dropdown/upload.vue'; import NewEntryButton from './new_dropdown/button.vue'; @@ -18,15 +18,10 @@ export default { ...mapGetters(['currentProject', 'currentTree', 'activeFile', 'getUrlForPath']), }, mounted() { - if (!this.activeFile) return; - - if (this.activeFile.pending && !this.activeFile.deleted) { - this.$router.push(this.getUrlForPath(this.activeFile.path), () => { - this.updateViewer('editor'); - }); - } else if (this.activeFile.deleted) { - this.resetOpenFiles(); - } + this.initialize(); + }, + activated() { + this.initialize(); }, methods: { ...mapActions(['updateViewer', 'createTempEntry', 'resetOpenFiles']), @@ -36,12 +31,27 @@ export default { createNewFolder() { this.$refs.newModal.open(modalTypes.tree); }, + initialize() { + this.$nextTick(() => { + this.updateViewer(viewerTypes.edit); + }); + + if (!this.activeFile) return; + + if (this.activeFile.pending && !this.activeFile.deleted) { + this.$router.push(this.getUrlForPath(this.activeFile.path), () => { + this.updateViewer(viewerTypes.edit); + }); + } else if (this.activeFile.deleted) { + this.resetOpenFiles(); + } + }, }, }; </script> <template> - <ide-tree-list viewer-type="editor"> + <ide-tree-list> <template #header> {{ __('Edit') }} <div class="ide-tree-actions ml-auto d-flex"> diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index 776d8459515..dd226f07fb0 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -2,6 +2,13 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import FileTree from '~/vue_shared/components/file_tree.vue'; +import { + WEBIDE_MARK_TREE_START, + WEBIDE_MEASURE_TREE_FROM_REQUEST, + WEBIDE_MARK_FILE_CLICKED, +} from '~/performance_constants'; +import { performanceMarkAndMeasure } from '~/performance_utils'; +import eventHub from '../eventhub'; import IdeFileRow from './ide_file_row.vue'; import NavDropdown from './nav_dropdown.vue'; @@ -12,10 +19,6 @@ export default { FileTree, }, props: { - viewerType: { - type: String, - required: true, - }, headerClass: { type: String, required: false, @@ -29,11 +32,19 @@ export default { return !this.currentTree || this.currentTree.loading; }, }, - mounted() { - this.updateViewer(this.viewerType); + beforeCreate() { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_TREE_START }); + }, + updated() { + if (this.currentTree?.tree?.length) { + eventHub.$emit(WEBIDE_MEASURE_TREE_FROM_REQUEST); + } }, methods: { - ...mapActions(['updateViewer', 'toggleTreeOpen']), + ...mapActions(['toggleTreeOpen']), + clickedFile() { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_CLICKED }); + }, }, IdeFileRow, }; @@ -51,7 +62,7 @@ export default { <nav-dropdown /> <slot name="header"></slot> </header> - <div class="ide-tree-body h-100"> + <div class="ide-tree-body h-100" data-testid="ide-tree-body"> <template v-if="currentTree.tree.length"> <file-tree v-for="file in currentTree.tree" @@ -60,6 +71,7 @@ export default { :level="0" :file-row-component="$options.IdeFileRow" @toggleTreeOpen="toggleTreeOpen" + @clickFile="clickedFile" /> </template> <div v-else class="file-row">{{ __('No files') }}</div> diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue index 11033a5cc88..a5ae8bbfe9a 100644 --- a/app/assets/javascripts/ide/components/jobs/detail.vue +++ b/app/assets/javascripts/ide/components/jobs/detail.vue @@ -2,9 +2,8 @@ /* eslint-disable vue/no-v-html */ import { mapActions, mapState } from 'vuex'; import { throttle } from 'lodash'; -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '../../../locale'; -import tooltip from '../../../vue_shared/directives/tooltip'; import ScrollButton from './detail/scroll_button.vue'; import JobDescription from './detail/description.vue'; @@ -15,7 +14,7 @@ const scrollPositions = { export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlIcon, @@ -84,7 +83,7 @@ export default { <job-description :job="detailJob" /> <div class="controllers ml-auto"> <a - v-tooltip + v-gl-tooltip :title="__('Show complete raw log')" :href="detailJob.rawPath" data-placement="top" @@ -92,7 +91,7 @@ export default { class="controllers-buttons" target="_blank" > - <i aria-hidden="true" class="fa fa-file-text-o"></i> + <gl-icon name="doc-text" aria-hidden="true" /> </a> <scroll-button :disabled="isScrolledToTop" direction="up" @click="scrollUp" /> <scroll-button :disabled="isScrolledToBottom" direction="down" @click="scrollDown" /> diff --git a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue index 2c679a3edc7..f4859b9f312 100644 --- a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue +++ b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue @@ -1,7 +1,6 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '../../../../locale'; -import tooltip from '../../../../vue_shared/directives/tooltip'; const directions = { up: 'up', @@ -10,7 +9,7 @@ const directions = { export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlIcon, @@ -46,7 +45,7 @@ export default { <template> <div - v-tooltip + v-gl-tooltip :title="tooltipTitle" class="controllers-buttons" data-container="body" diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 0b643947139..6c7f084c164 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -1,12 +1,11 @@ <script> -import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import tooltip from '../../../vue_shared/directives/tooltip'; +import { GlLoadingIcon, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import CiIcon from '../../../vue_shared/components/ci_icon.vue'; import Item from './item.vue'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlIcon, @@ -67,7 +66,7 @@ export default { <ci-icon :status="stage.status" :size="24" /> <strong ref="stageTitle" - v-tooltip="showTooltip" + v-gl-tooltip="showTooltip" :title="showTooltip ? stage.name : null" data-container="body" class="gl-ml-3 text-truncate" diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 528475849de..5ad836f346a 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -152,6 +152,7 @@ export default { v-model.trim="entryName" type="text" class="form-control" + data-testid="file-name-field" data-qa-selector="file_name_field" :placeholder="placeholder" /> diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 84ff05c9750..4a9a2a57acd 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -35,7 +35,7 @@ export default { name: `${this.path ? `${this.path}/` : ''}${name}`, type: 'blob', content, - rawPath: !isText ? target.result : '', + rawPath: !isText ? URL.createObjectURL(file) : '', }); if (isText) { @@ -44,7 +44,7 @@ export default { reader.addEventListener('load', e => emitCreateEvent(e.target.result), { once: true }); reader.readAsText(file); } else { - emitCreateEvent(encodedContent); + emitCreateEvent(rawContent); } }, readFile(file) { diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 5eed57bb6c5..92b99b5c731 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -26,28 +26,34 @@ export default { }, }, mounted() { - const file = - this.lastOpenedFile && this.lastOpenedFile.type !== 'tree' - ? this.lastOpenedFile - : this.activeFile; - - if (!file) return; - - this.openPendingTab({ - file, - keyPrefix: file.staged ? stageKeys.staged : stageKeys.unstaged, - }) - .then(changeViewer => { - if (changeViewer) { - this.updateViewer('diff'); - } - }) - .catch(e => { - throw e; - }); + this.initialize(); + }, + activated() { + this.initialize(); }, methods: { ...mapActions(['openPendingTab', 'updateViewer', 'updateActivityBarView']), + initialize() { + const file = + this.lastOpenedFile && this.lastOpenedFile.type !== 'tree' + ? this.lastOpenedFile + : this.activeFile; + + if (!file) return; + + this.openPendingTab({ + file, + keyPrefix: file.staged ? stageKeys.staged : stageKeys.unstaged, + }) + .then(changeViewer => { + if (changeViewer) { + this.updateViewer('diff'); + } + }) + .catch(e => { + throw e; + }); + }, }, stageKeys, }; diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index f342ce1739c..56bbb6349cd 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -5,6 +5,14 @@ import { deprecatedCreateFlash as flash } from '~/flash'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { + WEBIDE_MARK_FILE_CLICKED, + WEBIDE_MARK_FILE_START, + WEBIDE_MEASURE_FILE_AFTER_INTERACTION, + WEBIDE_MEASURE_FILE_FROM_REQUEST, +} from '~/performance_constants'; +import { performanceMarkAndMeasure } from '~/performance_utils'; +import eventHub from '../eventhub'; +import { leftSidebarViews, viewerTypes, FILE_VIEW_MODE_EDITOR, @@ -60,7 +68,7 @@ export default { ]), ...mapGetters('fileTemplates', ['showFileTemplatesBar']), shouldHideEditor() { - return this.file && !isTextFile(this.file); + return this.file && !this.file.loading && !isTextFile(this.file); }, showContentViewer() { return ( @@ -164,6 +172,9 @@ export default { } }, }, + beforeCreate() { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_START }); + }, beforeDestroy() { this.editor.dispose(); }, @@ -224,6 +235,7 @@ export default { return this.getFileData({ path: this.file.path, makeFileActive: false, + toggleLoading: false, }).then(() => this.getRawFileData({ path: this.file.path, @@ -289,6 +301,11 @@ export default { }); this.$emit('editorSetup'); + if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) { + eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION); + } else { + eventHub.$emit(WEBIDE_MEASURE_FILE_FROM_REQUEST); + } }, refreshEditorDimensions() { if (this.showEditor) { diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 59b1969face..bdb11e6b004 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -47,9 +47,9 @@ export const diffViewerErrors = Object.freeze({ }); export const leftSidebarViews = { - edit: { name: 'ide-tree', keepAlive: false }, - review: { name: 'ide-review', keepAlive: false }, - commit: { name: 'repo-commit-section', keepAlive: false }, + edit: { name: 'ide-tree' }, + review: { name: 'ide-review' }, + commit: { name: 'repo-commit-section' }, }; export const rightSidebarViews = { diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 7c767009de5..56d48e87c18 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -73,11 +73,9 @@ export function initIde(el, options = {}) { * @param {Objects} options - Extra options for the IDE (Used by EE). */ export function startIde(options) { - document.addEventListener('DOMContentLoaded', () => { - const ideElement = document.getElementById('ide'); - if (ideElement) { - resetServiceWorkersPublicPath(); - initIde(ideElement, options); - } - }); + const ideElement = document.getElementById('ide'); + if (ideElement) { + resetServiceWorkersPublicPath(); + initIde(ideElement, options); + } } diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 2b12230c7cd..493dedcd89a 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -157,8 +157,10 @@ export default class Editor { } updateDimensions() { - this.instance.layout(); - this.updateDiffView(); + if (this.instance) { + this.instance.layout(); + this.updateDiffView(); + } } setPosition({ lineNumber, column }) { diff --git a/app/assets/javascripts/ide/lib/errors.js b/app/assets/javascripts/ide/lib/errors.js index 6ae18bc8180..e62d9d1e77f 100644 --- a/app/assets/javascripts/ide/lib/errors.js +++ b/app/assets/javascripts/ide/lib/errors.js @@ -1,25 +1,49 @@ import { escape } from 'lodash'; import { __ } from '~/locale'; +import consts from '../stores/modules/commit/constants'; const CODEOWNERS_REGEX = /Push.*protected branches.*CODEOWNERS/; const BRANCH_CHANGED_REGEX = /changed.*since.*start.*edit/; +const BRANCH_ALREADY_EXISTS = /branch.*already.*exists/; -export const createUnexpectedCommitError = () => ({ +const createNewBranchAndCommit = store => + store + .dispatch('commit/updateCommitAction', consts.COMMIT_TO_NEW_BRANCH) + .then(() => store.dispatch('commit/commitChanges')); + +export const createUnexpectedCommitError = message => ({ title: __('Unexpected error'), - messageHTML: __('Could not commit. An unexpected error occurred.'), - canCreateBranch: false, + messageHTML: escape(message) || __('Could not commit. An unexpected error occurred.'), }); export const createCodeownersCommitError = message => ({ title: __('CODEOWNERS rule violation'), messageHTML: escape(message), - canCreateBranch: true, + primaryAction: { + text: __('Create new branch'), + callback: createNewBranchAndCommit, + }, }); export const createBranchChangedCommitError = message => ({ title: __('Branch changed'), messageHTML: `${escape(message)}<br/><br/>${__('Would you like to create a new branch?')}`, - canCreateBranch: true, + primaryAction: { + text: __('Create new branch'), + callback: createNewBranchAndCommit, + }, +}); + +export const branchAlreadyExistsCommitError = message => ({ + title: __('Branch already exists'), + messageHTML: `${escape(message)}<br/><br/>${__( + 'Would you like to try auto-generating a branch name?', + )}`, + primaryAction: { + text: __('Create new branch'), + callback: store => + store.dispatch('commit/addSuffixToBranchName').then(() => createNewBranchAndCommit(store)), + }, }); export const parseCommitError = e => { @@ -33,7 +57,9 @@ export const parseCommitError = e => { return createCodeownersCommitError(message); } else if (BRANCH_CHANGED_REGEX.test(message)) { return createBranchChangedCommitError(message); + } else if (BRANCH_ALREADY_EXISTS.test(message)) { + return branchAlreadyExistsCommitError(message); } - return createUnexpectedCommitError(); + return createUnexpectedCommitError(message); }; diff --git a/app/assets/javascripts/ide/lib/languages/README.md b/app/assets/javascripts/ide/lib/languages/README.md index e4d1a4c7818..c4f3de00783 100644 --- a/app/assets/javascripts/ide/lib/languages/README.md +++ b/app/assets/javascripts/ide/lib/languages/README.md @@ -1,7 +1,7 @@ # Web IDE Languages The Web IDE uses the [Monaco editor](https://microsoft.github.io/monaco-editor/) which uses the [Monarch library](https://microsoft.github.io/monaco-editor/monarch.html) for syntax highlighting. -The Web IDE currently supports all langauges defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository. +The Web IDE currently supports all languages defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository. ## Adding New Languages @@ -14,7 +14,7 @@ Should you be willing to help us and add support to GitLab for any missing langu 2. Create a new file in this folder called `{languageName}.js`, where `{languageName}` is the name of the language you want to add support for. 3. Follow the [Monarch documentation](https://microsoft.github.io/monaco-editor/monarch.html) to add a configuration for the new language. - Example: The [`vue.js`](./vue.js) file in the current directory adds support for Vue.js Syntax Highlighting. -4. Add tests for the new langauge implementation in `spec/frontend/ide/lib/languages/{langaugeName}.js`. +4. Add tests for the new language implementation in `spec/frontend/ide/lib/languages/{langaugeName}.js`. - Example: See [`vue_spec.js`](spec/frontend/ide/lib/languages/vue_spec.js). 5. Create a [Merge Request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) with your newly added language. diff --git a/app/assets/javascripts/ide/lib/languages/hcl.js b/app/assets/javascripts/ide/lib/languages/hcl.js new file mode 100644 index 00000000000..4539719b1f2 --- /dev/null +++ b/app/assets/javascripts/ide/lib/languages/hcl.js @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable no-useless-escape */ +/* eslint-disable @gitlab/require-i18n-strings */ + +const conf = { + comments: { + lineComment: '//', + blockComment: ['/*', '*/'], + }, + brackets: [['{', '}'], ['[', ']'], ['(', ')']], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"', notIn: ['string'] }, + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + ], +}; + +const language = { + defaultToken: '', + tokenPostfix: '.hcl', + + keywords: [ + 'var', + 'local', + 'path', + 'for_each', + 'any', + 'string', + 'number', + 'bool', + 'true', + 'false', + 'null', + 'if ', + 'else ', + 'endif ', + 'for ', + 'in', + 'endfor', + ], + + operators: [ + '=', + '>=', + '<=', + '==', + '!=', + '+', + '-', + '*', + '/', + '%', + '&&', + '||', + '!', + '<', + '>', + '?', + '...', + ':', + ], + + symbols: /[=><!~?:&|+\-*\/\^%]+/, + escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/, + terraformFunctions: /(abs|ceil|floor|log|max|min|pow|signum|chomp|format|formatlist|indent|join|lower|regex|regexall|replace|split|strrev|substr|title|trimspace|upper|chunklist|coalesce|coalescelist|compact|concat|contains|distinct|element|flatten|index|keys|length|list|lookup|map|matchkeys|merge|range|reverse|setintersection|setproduct|setunion|slice|sort|transpose|values|zipmap|base64decode|base64encode|base64gzip|csvdecode|jsondecode|jsonencode|urlencode|yamldecode|yamlencode|abspath|dirname|pathexpand|basename|file|fileexists|fileset|filebase64|templatefile|formatdate|timeadd|timestamp|base64sha256|base64sha512|bcrypt|filebase64sha256|filebase64sha512|filemd5|filemd1|filesha256|filesha512|md5|rsadecrypt|sha1|sha256|sha512|uuid|uuidv5|cidrhost|cidrnetmask|cidrsubnet|tobool|tolist|tomap|tonumber|toset|tostring)/, + terraformMainBlocks: /(module|data|terraform|resource|provider|variable|output|locals)/, + tokenizer: { + root: [ + // highlight main blocks + [ + /^@terraformMainBlocks([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)(\{)/, + ['type', '', 'string', '', 'string', '', '@brackets'], + ], + // highlight all the remaining blocks + [ + /(\w+[ \t]+)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)(\{)/, + ['identifier', '', 'string', '', 'string', '', '@brackets'], + ], + // highlight block + [ + /(\w+[ \t]+)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)([\w-]+|"[\w-]+"|)(=)(\{)/, + ['identifier', '', 'string', '', 'operator', '', '@brackets'], + ], + // terraform general highlight - shared with expressions + { include: '@terraform' }, + ], + terraform: [ + // highlight terraform functions + [/@terraformFunctions(\()/, ['type', '@brackets']], + // all other words are variables or keywords + [ + /[a-zA-Z_]\w*-*/, // must work with variables such as foo-bar and also with negative numbers + { + cases: { + '@keywords': { token: 'keyword.$0' }, + '@default': 'variable', + }, + }, + ], + { include: '@whitespace' }, + { include: '@heredoc' }, + // delimiters and operators + [/[{}()\[\]]/, '@brackets'], + [/[<>](?!@symbols)/, '@brackets'], + [ + /@symbols/, + { + cases: { + '@operators': 'operator', + '@default': '', + }, + }, + ], + // numbers + [/\d*\d+[eE]([\-+]?\d+)?/, 'number.float'], + [/\d*\.\d+([eE][\-+]?\d+)?/, 'number.float'], + [/\d[\d']*/, 'number'], + [/\d/, 'number'], + [/[;,.]/, 'delimiter'], // delimiter: after number because of .\d floats + // strings + [/"/, 'string', '@string'], // this will include expressions + [/'/, 'invalid'], + ], + heredoc: [ + [ + /<<[-]*\s*["]?([\w\-]+)["]?/, + { token: 'string.heredoc.delimiter', next: '@heredocBody.$1' }, + ], + ], + heredocBody: [ + [ + /^([\w\-]+)$/, + { + cases: { + '$1==$S2': [ + { + token: 'string.heredoc.delimiter', + next: '@popall', + }, + ], + '@default': 'string.heredoc', + }, + }, + ], + [/./, 'string.heredoc'], + ], + whitespace: [ + [/[ \t\r\n]+/, ''], + [/\/\*/, 'comment', '@comment'], + [/\/\/.*$/, 'comment'], + [/#.*$/, 'comment'], + ], + comment: [[/[^\/*]+/, 'comment'], [/\*\//, 'comment', '@pop'], [/[\/*]/, 'comment']], + string: [ + [/\$\{/, { token: 'delimiter', next: '@stringExpression' }], + [/[^\\"\$]+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/"/, 'string', '@popall'], + ], + stringInsideExpression: [ + [/[^\\"]+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/"/, 'string', '@pop'], + ], + stringExpression: [ + [/\}/, { token: 'delimiter', next: '@pop' }], + [/"/, 'string', '@stringInsideExpression'], + { include: '@terraform' }, + ], + }, +}; + +export default { + id: 'hcl', + extensions: ['.tf', '.tfvars', '.hcl'], + aliases: ['Terraform', 'tf', 'HCL', 'hcl'], + conf, + language, +}; diff --git a/app/assets/javascripts/ide/lib/languages/index.js b/app/assets/javascripts/ide/lib/languages/index.js index 0c85a1104fc..580ad820bf9 100644 --- a/app/assets/javascripts/ide/lib/languages/index.js +++ b/app/assets/javascripts/ide/lib/languages/index.js @@ -1,5 +1,6 @@ import vue from './vue'; +import hcl from './hcl'; -const languages = [vue]; +const languages = [vue, hcl]; export default languages; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 3515d1fc933..a0df85540f9 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -59,7 +59,7 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => { export const getFileData = ( { state, commit, dispatch, getters }, - { path, makeFileActive = true, openFile = makeFileActive }, + { path, makeFileActive = true, openFile = makeFileActive, toggleLoading = true }, ) => { const file = state.entries[path]; const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path); @@ -99,7 +99,7 @@ export const getFileData = ( }); }) .finally(() => { - commit(types.TOGGLE_LOADING, { entry: file, forceValue: false }); + if (toggleLoading) commit(types.TOGGLE_LOADING, { entry: file, forceValue: false }); }); }; diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index b8304a9b68d..500ce9f32d5 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -6,6 +6,7 @@ import { PERMISSION_CREATE_MR, PERMISSION_PUSH_CODE, } from '../constants'; +import { addNumericSuffix } from '~/ide/utils'; import Api from '~/api'; export const activeFile = state => state.openFiles.find(file => file.active) || null; @@ -167,10 +168,7 @@ export const getAvailableFileName = (state, getters) => path => { let newPath = path; while (getters.entryExists(newPath)) { - newPath = newPath.replace( - /([ _-]?)(\d*)(\..+?$|$)/, - (_, before, number, after) => `${before || '_'}${Number(number) + 1}${after}`, - ); + newPath = addNumericSuffix(newPath); } return newPath; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 90a6c644d17..e0d2028d2e1 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -8,6 +8,7 @@ import consts from './constants'; import { leftSidebarViews } from '../../../constants'; import eventHub from '../../../eventhub'; import { parseCommitError } from '../../../lib/errors'; +import { addNumericSuffix } from '~/ide/utils'; export const updateCommitMessage = ({ commit }, message) => { commit(types.UPDATE_COMMIT_MESSAGE, message); @@ -17,11 +18,8 @@ export const discardDraft = ({ commit }) => { commit(types.UPDATE_COMMIT_MESSAGE, ''); }; -export const updateCommitAction = ({ commit, getters }, commitAction) => { - commit(types.UPDATE_COMMIT_ACTION, { - commitAction, - }); - commit(types.TOGGLE_SHOULD_CREATE_MR, !getters.shouldHideNewMrOption); +export const updateCommitAction = ({ commit }, commitAction) => { + commit(types.UPDATE_COMMIT_ACTION, { commitAction }); }; export const toggleShouldCreateMR = ({ commit }) => { @@ -32,6 +30,12 @@ export const updateBranchName = ({ commit }, branchName) => { commit(types.UPDATE_NEW_BRANCH_NAME, branchName); }; +export const addSuffixToBranchName = ({ commit, state }) => { + const newBranchName = addNumericSuffix(state.newBranchName, true); + + commit(types.UPDATE_NEW_BRANCH_NAME, newBranchName); +}; + export const setLastCommitMessage = ({ commit, rootGetters }, data) => { const { currentProject } = rootGetters; const commitStats = data.stats @@ -107,7 +111,7 @@ export const updateFilesAfterCommit = ({ commit, dispatch, rootState, rootGetter export const commitChanges = ({ commit, state, getters, dispatch, rootState, rootGetters }) => { // Pull commit options out because they could change // During some of the pre and post commit processing - const { shouldCreateMR, isCreatingNewBranch, branchName } = getters; + const { shouldCreateMR, shouldHideNewMrOption, isCreatingNewBranch, branchName } = getters; const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH; const stageFilesPromise = rootState.stagedFiles.length ? Promise.resolve() @@ -167,7 +171,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true }); }, 5000); - if (shouldCreateMR) { + if (shouldCreateMR && !shouldHideNewMrOption) { const { currentProject } = rootGetters; const targetBranch = isCreatingNewBranch ? rootState.currentBranchId diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js index 2cf6e8e6f36..c4bfad6405e 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js @@ -10,9 +10,7 @@ export default { Object.assign(state, { commitAction }); }, [types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) { - Object.assign(state, { - newBranchName, - }); + Object.assign(state, { newBranchName }); }, [types.UPDATE_LOADING](state, submitCommitLoading) { Object.assign(state, { diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index c90bc2a3320..a981f86fa40 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -19,19 +19,20 @@ export default { } }, [types.TOGGLE_FILE_OPEN](state, path) { - Object.assign(state.entries[path], { - opened: !state.entries[path].opened, - }); + const entry = state.entries[path]; - if (state.entries[path].opened) { + entry.opened = !entry.opened; + if (entry.opened && !entry.tempFile) { + entry.loading = true; + } + + if (entry.opened) { Object.assign(state, { openFiles: state.openFiles.filter(f => f.path !== path).concat(state.entries[path]), }); } else { - const file = state.entries[path]; - Object.assign(state, { - openFiles: state.openFiles.filter(f => f.key !== file.key), + openFiles: state.openFiles.filter(f => f.key !== entry.key), }); } }, diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index d9cdc7727ad..b7ced3a271a 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -3,7 +3,7 @@ import { relativePathToAbsolute, isAbsolute, isRootRelative, - isBase64DataUrl, + isBlobUrl, } from '~/lib/utils/url_utility'; export const dataStructure = () => ({ @@ -110,14 +110,19 @@ export const createCommitPayload = ({ }) => ({ branch, commit_message: state.commitMessage || getters.preBuiltCommitMessage, - actions: getCommitFiles(rootState.stagedFiles).map(f => ({ - action: commitActionForFile(f), - file_path: f.path, - previous_path: f.prevPath || undefined, - content: f.prevPath && !f.changed ? null : f.content || undefined, - encoding: isBase64DataUrl(f.rawPath) ? 'base64' : 'text', - last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha, - })), + actions: getCommitFiles(rootState.stagedFiles).map(f => { + const isBlob = isBlobUrl(f.rawPath); + const content = isBlob ? btoa(f.content) : f.content; + + return { + action: commitActionForFile(f), + file_path: f.path, + previous_path: f.prevPath || undefined, + content: f.prevPath && !f.changed ? null : content || undefined, + encoding: isBlob ? 'base64' : 'text', + last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha, + }; + }), start_sha: newBranch ? rootGetters.lastCommit.id : undefined, }); diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index cde53e1ef00..4cf4f5e1d81 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -1,6 +1,7 @@ import { languages } from 'monaco-editor'; import { flatten, isString } from 'lodash'; import { SIDE_LEFT, SIDE_RIGHT } from './constants'; +import { performanceMarkAndMeasure } from '~/performance_utils'; const toLowerCase = x => x.toLowerCase(); @@ -42,16 +43,17 @@ const KNOWN_TYPES = [ }, ]; -export function isTextFile({ name, content, mimeType = '' }) { +export function isTextFile({ name, raw, content, mimeType = '' }) { const knownType = KNOWN_TYPES.find(type => type.isMatch(mimeType, name)); - if (knownType) return knownType.isText; // does the string contain ascii characters only (ranges from space to tilde, tabs and new lines) const asciiRegex = /^[ -~\t\n\r]+$/; + const fileContents = raw || content; + // for unknown types, determine the type by evaluating the file contents - return isString(content) && (content === '' || asciiRegex.test(content)); + return isString(fileContents) && (fileContents === '' || asciiRegex.test(fileContents)); } export const createPathWithExt = p => { @@ -137,3 +139,49 @@ export function readFileAsDataURL(file) { export function getFileEOL(content = '') { return content.includes('\r\n') ? 'CRLF' : 'LF'; } + +/** + * Adds or increments the numeric suffix to a filename/branch name. + * Retains underscore or dash before the numeric suffix if it already exists. + * + * Examples: + * hello -> hello-1 + * hello-2425 -> hello-2425 + * hello.md -> hello-1.md + * hello_2.md -> hello_3.md + * hello_ -> hello_1 + * master-patch-22432 -> master-patch-22433 + * patch_332 -> patch_333 + * + * @param {string} filename File name or branch name + * @param {number} [randomize] Should randomize the numeric suffix instead of auto-incrementing? + */ +export function addNumericSuffix(filename, randomize = false) { + return filename.replace(/([ _-]?)(\d*)(\..+?$|$)/, (_, before, number, after) => { + const n = randomize + ? Math.random() + .toString() + .substring(2, 7) + .slice(-5) + : Number(number) + 1; + return `${before || '-'}${n}${after}`; + }); +} + +export const measurePerformance = ( + mark, + measureName, + measureStart = undefined, + measureEnd = mark, +) => { + performanceMarkAndMeasure({ + mark, + measures: [ + { + name: measureName, + start: measureStart, + end: measureEnd, + }, + ], + }); +}; diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 670c42cbdac..e1f9d858f2b 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -2,95 +2,107 @@ import { GlLoadingIcon, GlTable, - GlAlert, GlAvatarsInline, GlAvatarLink, GlAvatar, GlTooltipDirective, GlButton, - GlSearchBoxByType, GlIcon, - GlPagination, - GlTabs, - GlTab, - GlBadge, GlEmptyState, } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import Tracking from '~/tracking'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue'; +import { + tdClass, + thClass, + bodyTrClass, + initialPaginationState, +} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants'; import { convertToSnakeCase } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; -import { mergeUrlParams, joinPaths, visitUrl } from '~/lib/utils/url_utility'; +import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility'; import getIncidents from '../graphql/queries/get_incidents.query.graphql'; import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; import SeverityToken from '~/sidebar/components/severity/severity.vue'; import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; -import { I18N, DEFAULT_PAGE_SIZE, INCIDENT_SEARCH_DELAY, INCIDENT_STATUS_TABS } from '../constants'; - -const TH_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; -const tdClass = - 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap'; -const thClass = 'gl-hover-bg-blue-50'; -const bodyTrClass = - 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-b-solid gl-hover-border-blue-200'; - -const initialPaginationState = { - currentPage: 1, - prevPageCursor: '', - nextPageCursor: '', - firstPageSize: DEFAULT_PAGE_SIZE, - lastPageSize: null, -}; +import { + I18N, + INCIDENT_STATUS_TABS, + TH_CREATED_AT_TEST_ID, + TH_INCIDENT_SLA_TEST_ID, + TH_SEVERITY_TEST_ID, + TH_PUBLISHED_TEST_ID, + INCIDENT_DETAILS_PATH, + trackIncidentCreateNewOptions, + trackIncidentListViewsOptions, +} from '../constants'; export default { + trackIncidentCreateNewOptions, + trackIncidentListViewsOptions, i18n: I18N, statusTabs: INCIDENT_STATUS_TABS, fields: [ { key: 'severity', label: s__('IncidentManagement|Severity'), - thClass: `gl-pointer-events-none`, - tdClass, + thClass: `${thClass} w-15p`, + tdClass: `${tdClass} sortable-cell`, + sortable: true, + thAttr: TH_SEVERITY_TEST_ID, }, { key: 'title', label: s__('IncidentManagement|Incident'), - thClass: `gl-pointer-events-none gl-w-half`, + thClass: `gl-pointer-events-none`, tdClass, }, { key: 'createdAt', label: s__('IncidentManagement|Date created'), - thClass, + thClass: `${thClass} gl-w-eighth`, tdClass: `${tdClass} sortable-cell`, sortable: true, - thAttr: TH_TEST_ID, + thAttr: TH_CREATED_AT_TEST_ID, + }, + { + key: 'incidentSla', + label: s__('IncidentManagement|Time to SLA'), + thClass: `gl-pointer-events-none gl-text-right gl-w-eighth`, + tdClass: `${tdClass} gl-text-right`, + thAttr: TH_INCIDENT_SLA_TEST_ID, }, { key: 'assignees', label: s__('IncidentManagement|Assignees'), - thClass: 'gl-pointer-events-none', + thClass: 'gl-pointer-events-none w-15p', tdClass, }, + { + key: 'published', + label: s__('IncidentManagement|Published'), + thClass: `${thClass} w-15p`, + tdClass: `${tdClass} sortable-cell`, + sortable: true, + thAttr: TH_PUBLISHED_TEST_ID, + }, ], components: { GlLoadingIcon, GlTable, - GlAlert, GlAvatarsInline, GlAvatarLink, GlAvatar, GlButton, TimeAgoTooltip, - GlSearchBoxByType, GlIcon, - GlPagination, - GlTabs, - GlTab, PublishedCell: () => import('ee_component/incidents/components/published_cell.vue'), - GlBadge, + ServiceLevelAgreementCell: () => + import('ee_component/incidents/components/service_level_agreement_cell.vue'), GlEmptyState, SeverityToken, + PaginatedTableWithSearchAndTabs, }, directives: { GlTooltip: GlTooltipDirective, @@ -103,6 +115,10 @@ export default { 'issuePath', 'publishedAvailable', 'emptyListSvgPath', + 'textQuery', + 'authorUsernameQuery', + 'assigneeUsernameQuery', + 'slaFeatureAvailable', ], apollo: { incidents: { @@ -110,8 +126,10 @@ export default { variables() { return { searchTerm: this.searchTerm, - status: this.statusFilter, + authorUsername: this.authorUsername, + assigneeUsername: this.assigneeUsername, projectPath: this.projectPath, + status: this.statusFilter, issueTypes: ['INCIDENT'], sort: this.sort, firstPageSize: this.pagination.firstPageSize, @@ -135,6 +153,8 @@ export default { variables() { return { searchTerm: this.searchTerm, + authorUsername: this.authorUsername, + assigneeUsername: this.assigneeUsername, projectPath: this.projectPath, issueTypes: ['INCIDENT'], }; @@ -149,14 +169,17 @@ export default { errored: false, isErrorAlertDismissed: false, redirecting: false, - searchTerm: '', - pagination: initialPaginationState, incidents: {}, + incidentsCount: {}, sort: 'created_desc', sortBy: 'createdAt', sortDesc: true, statusFilter: '', filteredByStatus: '', + searchTerm: this.textQuery, + authorUsername: this.authorUsernameQuery, + assigneeUsername: this.assigneeUsernameQuery, + pagination: initialPaginationState, }; }, computed: { @@ -166,29 +189,15 @@ export default { loading() { return this.$apollo.queries.incidents.loading; }, - hasIncidents() { - return this.incidents?.list?.length; - }, - incidentsForCurrentTab() { - return this.incidentsCount?.[this.filteredByStatus.toLowerCase()] ?? 0; - }, - showPaginationControls() { - return Boolean( - this.incidents?.pageInfo?.hasNextPage || this.incidents?.pageInfo?.hasPreviousPage, - ); - }, - prevPage() { - return Math.max(this.pagination.currentPage - 1, 0); + isEmpty() { + return !this.incidents?.list?.length; }, - nextPage() { - const nextPage = this.pagination.currentPage + 1; - return nextPage > Math.ceil(this.incidentsForCurrentTab / DEFAULT_PAGE_SIZE) - ? null - : nextPage; + showList() { + return !this.isEmpty || this.errored || this.loading; }, tbodyTrClass() { return { - [bodyTrClass]: !this.loading && this.hasIncidents, + [bodyTrClass]: !this.loading && !this.isEmpty, }; }, newIncidentPath() { @@ -201,24 +210,12 @@ export default { ); }, availableFields() { - return this.publishedAvailable - ? [ - ...this.$options.fields, - ...[ - { - key: 'published', - label: s__('IncidentManagement|Published'), - thClass: 'gl-pointer-events-none', - }, - ], - ] - : this.$options.fields; - }, - isEmpty() { - return !this.incidents.list?.length; - }, - showList() { - return !this.isEmpty || this.errored || this.loading; + const isHidden = { + published: !this.publishedAvailable, + incidentSla: !this.slaFeatureAvailable, + }; + + return this.$options.fields.filter(({ key }) => !isHidden[key]); }, activeClosedTabHasNoIncidents() { const { all, closed } = this.incidentsCount || {}; @@ -244,205 +241,181 @@ export default { }, }, methods: { - onInputChange: debounce(function debounceSearch(input) { - const trimmedInput = input.trim(); - if (trimmedInput !== this.searchTerm) { - this.searchTerm = trimmedInput; - } - }, INCIDENT_SEARCH_DELAY), - filterIncidentsByStatus(tabIndex) { - const { filters, status } = this.$options.statusTabs[tabIndex]; - this.statusFilter = filters; - this.filteredByStatus = status; - }, hasAssignees(assignees) { return Boolean(assignees.nodes?.length); }, navigateToIncidentDetails({ iid }) { - return visitUrl(joinPaths(this.issuePath, iid)); + return visitUrl(joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid)); }, - handlePageChange(page) { - const { startCursor, endCursor } = this.incidents.pageInfo; - - if (page > this.pagination.currentPage) { - this.pagination = { - ...initialPaginationState, - nextPageCursor: endCursor, - currentPage: page, - }; - } else { - this.pagination = { - lastPageSize: DEFAULT_PAGE_SIZE, - firstPageSize: null, - prevPageCursor: startCursor, - nextPageCursor: '', - currentPage: page, - }; - } - }, - resetPagination() { - this.pagination = initialPaginationState; + navigateToCreateNewIncident() { + const { category, action } = this.$options.trackIncidentCreateNewOptions; + Tracking.event(category, action); + this.redirecting = true; }, fetchSortedData({ sortBy, sortDesc }) { - const sortingDirection = sortDesc ? 'desc' : 'asc'; - const sortingColumn = convertToSnakeCase(sortBy).replace(/_.*/, ''); + const sortingDirection = sortDesc ? 'DESC' : 'ASC'; + const sortingColumn = convertToSnakeCase(sortBy) + .replace(/_.*/, '') + .toUpperCase(); + this.pagination = initialPaginationState; this.sort = `${sortingColumn}_${sortingDirection}`; }, getSeverity(severity) { return INCIDENT_SEVERITY[severity]; }, + pageChanged(pagination) { + this.pagination = pagination; + }, + statusChanged({ filters, status }) { + this.statusFilter = filters; + this.filteredByStatus = status; + }, + filtersChanged({ searchTerm, authorUsername, assigneeUsername }) { + this.searchTerm = searchTerm; + this.authorUsername = authorUsername; + this.assigneeUsername = assigneeUsername; + }, + errorAlertDismissed() { + this.isErrorAlertDismissed = true; + }, }, }; </script> <template> - <div class="incident-management-list"> - <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true"> - {{ $options.i18n.errorMsg }} - </gl-alert> - - <div - class="incident-management-list-header gl-display-flex gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-gray-100" - > - <gl-tabs content-class="gl-p-0" @input="filterIncidentsByStatus"> - <gl-tab v-for="tab in $options.statusTabs" :key="tab.status" :data-testid="tab.status"> - <template #title> - <span>{{ tab.title }}</span> - <gl-badge v-if="incidentsCount" pill size="sm" class="gl-tab-counter-badge"> - {{ incidentsCount[tab.status.toLowerCase()] }} - </gl-badge> - </template> - </gl-tab> - </gl-tabs> - - <gl-button - v-if="!isEmpty || activeClosedTabHasNoIncidents" - class="gl-my-3 gl-mr-5 create-incident-button" - data-testid="createIncidentBtn" - data-qa-selector="create_incident_button" - :loading="redirecting" - :disabled="redirecting" - category="primary" - variant="success" - :href="newIncidentPath" - @click="redirecting = true" - > - {{ $options.i18n.createIncidentBtnLabel }} - </gl-button> - </div> - - <div class="gl-bg-gray-10 gl-p-5 gl-border-b-solid gl-border-b-1 gl-border-gray-100"> - <gl-search-box-by-type - :value="searchTerm" - class="gl-bg-white" - :placeholder="$options.i18n.searchPlaceholder" - @input="onInputChange" - /> - </div> - - <h4 class="gl-display-block d-md-none my-3"> - {{ s__('IncidentManagement|Incidents') }} - </h4> - <gl-table - v-if="showList" + <div> + <paginated-table-with-search-and-tabs + :show-items="showList" + :show-error-msg="showErrorMsg" + :i18n="$options.i18n" :items="incidents.list || []" - :fields="availableFields" - :show-empty="true" - :busy="loading" - stacked="md" - :tbody-tr-class="tbodyTrClass" - :no-local-sorting="true" - :sort-direction="'desc'" - :sort-desc.sync="sortDesc" - :sort-by.sync="sortBy" - sort-icon-left - fixed - @row-clicked="navigateToIncidentDetails" - @sort-changed="fetchSortedData" + :page-info="incidents.pageInfo" + :items-count="incidentsCount" + :status-tabs="$options.statusTabs" + :track-views-options="$options.trackIncidentListViewsOptions" + filter-search-key="incidents" + @page-changed="pageChanged" + @tabs-changed="statusChanged" + @filters-changed="filtersChanged" + @error-alert-dismissed="errorAlertDismissed" > - <template #cell(severity)="{ item }"> - <severity-token :severity="getSeverity(item.severity)" /> + <template #header-actions> + <gl-button + v-if="!isEmpty || activeClosedTabHasNoIncidents" + class="gl-my-3 gl-mr-5 create-incident-button" + data-testid="createIncidentBtn" + data-qa-selector="create_incident_button" + :loading="redirecting" + :disabled="redirecting" + category="primary" + variant="success" + :href="newIncidentPath" + @click="navigateToCreateNewIncident" + > + {{ $options.i18n.createIncidentBtnLabel }} + </gl-button> </template> - <template #cell(title)="{ item }"> - <div :class="{ 'gl-display-flex gl-align-items-center': item.state === 'closed' }"> - <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div> - <gl-icon - v-if="item.state === 'closed'" - name="issue-close" - class="gl-mx-1 gl-fill-blue-500 gl-flex-shrink-0" - :size="16" - data-testid="incident-closed" - /> - </div> + <template #title> + {{ s__('IncidentManagement|Incidents') }} </template> - <template #cell(createdAt)="{ item }"> - <time-ago-tooltip :time="item.createdAt" /> - </template> + <template #table> + <gl-table + :items="incidents.list || []" + :fields="availableFields" + :show-empty="true" + :busy="loading" + stacked="md" + :tbody-tr-class="tbodyTrClass" + :no-local-sorting="true" + :sort-direction="'desc'" + :sort-desc.sync="sortDesc" + :sort-by.sync="sortBy" + sort-icon-left + fixed + @row-clicked="navigateToIncidentDetails" + @sort-changed="fetchSortedData" + > + <template #cell(severity)="{ item }"> + <severity-token :severity="getSeverity(item.severity)" /> + </template> + + <template #cell(title)="{ item }"> + <div :class="{ 'gl-display-flex gl-align-items-center': item.state === 'closed' }"> + <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div> + <gl-icon + v-if="item.state === 'closed'" + name="issue-close" + class="gl-mx-1 gl-fill-blue-500 gl-flex-shrink-0" + :size="16" + data-testid="incident-closed" + /> + </div> + </template> + + <template #cell(createdAt)="{ item }"> + <time-ago-tooltip :time="item.createdAt" /> + </template> + + <template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }"> + <service-level-agreement-cell :sla-due-at="item.slaDueAt" data-testid="incident-sla" /> + </template> - <template #cell(assignees)="{ item }"> - <div data-testid="incident-assignees"> - <template v-if="hasAssignees(item.assignees)"> - <gl-avatars-inline - :avatars="item.assignees.nodes" - :collapsed="true" - :max-visible="4" - :avatar-size="24" - badge-tooltip-prop="name" - :badge-tooltip-max-chars="100" - > - <template #avatar="{ avatar }"> - <gl-avatar-link - :key="avatar.username" - v-gl-tooltip - target="_blank" - :href="avatar.webUrl" - :title="avatar.name" + <template #cell(assignees)="{ item }"> + <div data-testid="incident-assignees"> + <template v-if="hasAssignees(item.assignees)"> + <gl-avatars-inline + :avatars="item.assignees.nodes" + :collapsed="true" + :max-visible="4" + :avatar-size="24" + badge-tooltip-prop="name" + :badge-tooltip-max-chars="100" > - <gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" /> - </gl-avatar-link> + <template #avatar="{ avatar }"> + <gl-avatar-link + :key="avatar.username" + v-gl-tooltip + target="_blank" + :href="avatar.webUrl" + :title="avatar.name" + > + <gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" /> + </gl-avatar-link> + </template> + </gl-avatars-inline> </template> - </gl-avatars-inline> + <template v-else> + {{ $options.i18n.unassigned }} + </template> + </div> </template> - <template v-else> - {{ $options.i18n.unassigned }} + + <template v-if="publishedAvailable" #cell(published)="{ item }"> + <published-cell + :status-page-published-incident="item.statusPagePublishedIncident" + :un-published="$options.i18n.unPublished" + /> + </template> + <template #table-busy> + <gl-loading-icon size="lg" color="dark" class="mt-3" /> </template> - </div> - </template> - <template v-if="publishedAvailable" #cell(published)="{ item }"> - <published-cell - :status-page-published-incident="item.statusPagePublishedIncident" - :un-published="$options.i18n.unPublished" - /> - </template> - <template #table-busy> - <gl-loading-icon size="lg" color="dark" class="mt-3" /> + <template v-if="errored" #empty> + {{ $options.i18n.noIncidents }} + </template> + </gl-table> </template> - - <template v-if="errored" #empty> - {{ $options.i18n.noIncidents }} + <template #emtpy-state> + <gl-empty-state + :title="emptyStateData.title" + :svg-path="emptyListSvgPath" + :description="emptyStateData.description" + :primary-button-link="emptyStateData.btnLink" + :primary-button-text="emptyStateData.btnText" + /> </template> - </gl-table> - - <gl-empty-state - v-else - :title="emptyStateData.title" - :svg-path="emptyListSvgPath" - :description="emptyStateData.description" - :primary-button-link="emptyStateData.btnLink" - :primary-button-text="emptyStateData.btnText" - /> - - <gl-pagination - v-if="showPaginationControls" - :value="pagination.currentPage" - :prev-page="prevPage" - :next-page="nextPage" - align="center" - class="gl-pagination gl-mt-3" - @input="handlePageChange" - /> + </paginated-table-with-search-and-tabs> </div> </template> diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js index 289b36d9848..b82980b5628 100644 --- a/app/assets/javascripts/incidents/constants.js +++ b/app/assets/javascripts/incidents/constants.js @@ -1,4 +1,5 @@ -import { s__, __ } from '~/locale'; +/* eslint-disable @gitlab/require-i18n-strings */ +import { s__ } from '~/locale'; export const I18N = { errorMsg: s__('IncidentManagement|There was an error displaying the incidents.'), @@ -6,7 +7,6 @@ export const I18N = { unassigned: s__('IncidentManagement|Unassigned'), createIncidentBtnLabel: s__('IncidentManagement|Create incident'), unPublished: s__('IncidentManagement|Unpublished'), - searchPlaceholder: __('Search results…'), emptyState: { title: s__('IncidentManagement|Display your incidents in a dedicated view'), emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'), @@ -34,5 +34,33 @@ export const INCIDENT_STATUS_TABS = [ }, ]; -export const INCIDENT_SEARCH_DELAY = 300; export const DEFAULT_PAGE_SIZE = 20; +export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; +export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' }; +export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla' }; +export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' }; +export const INCIDENT_DETAILS_PATH = 'incident'; + +/** + * Tracks snowplow event when user clicks create new incident + */ +export const trackIncidentCreateNewOptions = { + category: 'Incident Management', + action: 'create_incident_button_clicks', +}; + +/** + * Tracks snowplow event when user views incidents list + */ +export const trackIncidentListViewsOptions = { + category: 'Incident Management', + action: 'view_incidents_list', +}; + +/** + * Tracks snowplow event when user views incident details + */ +export const trackIncidentDetailsViewsOptions = { + category: 'Incident Management', + action: 'view_incident_details', +}; diff --git a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql index 0b784b104a8..4e44a506c4f 100644 --- a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql +++ b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql @@ -1,6 +1,17 @@ -query getIncidentsCountByStatus($searchTerm: String, $projectPath: ID!, $issueTypes: [IssueType!]) { +query getIncidentsCountByStatus( + $searchTerm: String + $projectPath: ID! + $issueTypes: [IssueType!] + $authorUsername: String = "" + $assigneeUsername: String = "" +) { project(fullPath: $projectPath) { - issueStatusCounts(search: $searchTerm, types: $issueTypes) { + issueStatusCounts( + search: $searchTerm + types: $issueTypes + authorUsername: $authorUsername + assigneeUsername: $assigneeUsername + ) { all opened closed diff --git a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql index dab130835e2..f97664a3b77 100644 --- a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql +++ b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql @@ -9,7 +9,9 @@ query getIncidents( $lastPageSize: Int $prevPageCursor: String = "" $nextPageCursor: String = "" - $searchTerm: String + $searchTerm: String = "" + $authorUsername: String = "" + $assigneeUsername: String = "" ) { project(fullPath: $projectPath) { issues( @@ -17,6 +19,8 @@ query getIncidents( types: $issueTypes sort: $sort state: $status + authorUsername: $authorUsername + assigneeUsername: $assigneeUsername first: $firstPageSize last: $lastPageSize after: $nextPageCursor diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js index 7505d07449c..6f87fbbe775 100644 --- a/app/assets/javascripts/incidents/list.js +++ b/app/assets/javascripts/incidents/list.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; import IncidentsList from './components/incidents_list.vue'; Vue.use(VueApollo); @@ -16,6 +17,10 @@ export default () => { issuePath, publishedAvailable, emptyListSvgPath, + textQuery, + authorUsernameQuery, + assigneeUsernameQuery, + slaFeatureAvailable, } = domEl.dataset; const apolloProvider = new VueApollo({ @@ -30,8 +35,12 @@ export default () => { incidentType, newIssuePath, issuePath, - publishedAvailable, + publishedAvailable: parseBoolean(publishedAvailable), emptyListSvgPath, + textQuery, + authorUsernameQuery, + assigneeUsernameQuery, + slaFeatureAvailable: parseBoolean(slaFeatureAvailable), }, apolloProvider, components: { diff --git a/app/assets/javascripts/incidents_settings/components/alerts_form.vue b/app/assets/javascripts/incidents_settings/components/alerts_form.vue index 17a77f650e0..5fe0badc56e 100644 --- a/app/assets/javascripts/incidents_settings/components/alerts_form.vue +++ b/app/assets/javascripts/incidents_settings/components/alerts_form.vue @@ -130,18 +130,16 @@ export default { <span>{{ $options.i18n.autoCloseIncidents.label }}</span> </gl-form-checkbox> </gl-form-group> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button - ref="submitBtn" - data-qa-selector="save_changes_button" - :disabled="loading" - variant="success" - type="submit" - class="js-no-auto-disable" - > - {{ $options.i18n.saveBtnLabel }} - </gl-button> - </div> + <gl-button + ref="submitBtn" + data-qa-selector="save_changes_button" + :disabled="loading" + variant="success" + type="submit" + class="js-no-auto-disable" + > + {{ $options.i18n.saveBtnLabel }} + </gl-button> </form> </div> </template> diff --git a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue index d6e963c6f4f..c90ff8079b8 100644 --- a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue +++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue @@ -11,6 +11,8 @@ export default { GlTab, AlertsSettingsForm, PagerDutySettingsForm, + ServiceLevelAgreementForm: () => + import('ee_component/incidents_settings/components/service_level_agreement_form.vue'), }, tabs: INTEGRATION_TABS_CONFIG, i18n: I18N_INTEGRATION_TABS, @@ -45,6 +47,7 @@ export default { > <component :is="tab.component" class="gl-pt-3" :data-testid="`${tab.component}-tab`" /> </gl-tab> + <service-level-agreement-form /> </gl-tabs> </div> </section> diff --git a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue index 8b608d9f391..9a8c4bc5af9 100644 --- a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue +++ b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue @@ -124,7 +124,6 @@ export default { 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> @@ -149,17 +148,15 @@ export default { </template> </gl-sprintf> </div> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button - v-gl-modal.resetWebhookModal - class="gl-mt-3" - :disabled="loading" - :loading="resettingWebhook" - data-testid="webhook-reset-btn" - > - {{ $options.i18n.webhookUrl.resetWebhookUrl }} - </gl-button> - </div> + <gl-button + v-gl-modal.resetWebhookModal + class="gl-mt-3" + :disabled="loading" + :loading="resettingWebhook" + data-testid="webhook-reset-btn" + > + {{ $options.i18n.webhookUrl.resetWebhookUrl }} + </gl-button> <gl-modal modal-id="resetWebhookModal" :title="$options.i18n.webhookUrl.resetWebhookUrl" @@ -170,17 +167,15 @@ export default { {{ $options.i18n.webhookUrl.restKeyInfo }} </gl-modal> </gl-form-group> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button - ref="submitBtn" - :disabled="isSaveDisabled" - variant="success" - type="submit" - class="js-no-auto-disable" - > - {{ $options.i18n.saveBtnLabel }} - </gl-button> - </div> + <gl-button + ref="submitBtn" + :disabled="isSaveDisabled" + variant="success" + type="submit" + class="js-no-auto-disable" + > + {{ $options.i18n.saveBtnLabel }} + </gl-button> </form> </div> </template> diff --git a/app/assets/javascripts/incidents_settings/index.js b/app/assets/javascripts/incidents_settings/index.js index ad875d49768..e9ba4294519 100644 --- a/app/assets/javascripts/incidents_settings/index.js +++ b/app/assets/javascripts/incidents_settings/index.js @@ -21,6 +21,9 @@ export default () => { pagerdutyWebhookUrl, pagerdutyResetKeyPath, autoCloseIncident, + slaActive, + slaMinutes, + slaFeatureAvailable, }, } = el; @@ -40,6 +43,11 @@ export default () => { active: parseBoolean(pagerdutyActive), webhookUrl: pagerdutyWebhookUrl, }, + serviceLevelAgreementSettings: { + active: parseBoolean(slaActive), + minutes: slaMinutes, + available: parseBoolean(slaFeatureAvailable), + }, }, render(createElement) { return createElement(SettingsTabs); diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js index 528d5d8072f..1e82ecb05b5 100644 --- a/app/assets/javascripts/init_issuable_sidebar.js +++ b/app/assets/javascripts/init_issuable_sidebar.js @@ -5,10 +5,14 @@ import LabelsSelect from './labels_select'; import IssuableContext from './issuable_context'; import Sidebar from './right_sidebar'; import DueDateSelectors from './due_date_select'; -import { mountSidebarLabels } from '~/sidebar/mount_sidebar'; +import { mountSidebarLabels, getSidebarOptions } from '~/sidebar/mount_sidebar'; export default () => { - const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); + const sidebarOptEl = document.querySelector('.js-sidebar-options'); + + if (!sidebarOptEl) return; + + const sidebarOptions = getSidebarOptions(sidebarOptEl); new MilestoneSelect({ full_path: sidebarOptions.fullPath, diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue new file mode 100644 index 00000000000..890381a8f29 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue @@ -0,0 +1,60 @@ +<script> +import { mapGetters } from 'vuex'; +import { GlModal } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlModal, + }, + computed: { + ...mapGetters(['isSavingOrTesting']), + primaryProps() { + return { + text: __('Save'), + attributes: [ + { variant: 'success' }, + { category: 'primary' }, + { disabled: this.isSavingOrTesting }, + ], + }; + }, + cancelProps() { + return { + text: __('Cancel'), + }; + }, + }, + methods: { + onSubmit() { + this.$emit('submit'); + }, + }, +}; +</script> + +<template> + <gl-modal + modal-id="confirmSaveIntegration" + size="sm" + :title="s__('Integrations|Save settings?')" + :action-primary="primaryProps" + :action-cancel="cancelProps" + @primary="onSubmit" + > + <p> + {{ + s__( + 'Integrations|Saving will update the default settings for all projects that are not using custom settings.', + ) + }} + </p> + <p class="gl-mb-0"> + {{ + s__( + 'Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.', + ) + }} + </p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 0460ed6791e..0fd39c5635d 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -1,8 +1,9 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlModalDirective } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; +import { integrationLevels } from '../constants'; import OverrideDropdown from './override_dropdown.vue'; import ActiveCheckbox from './active_checkbox.vue'; @@ -10,6 +11,7 @@ import JiraTriggerFields from './jira_trigger_fields.vue'; import JiraIssuesFields from './jira_issues_fields.vue'; import TriggerFields from './trigger_fields.vue'; import DynamicField from './dynamic_field.vue'; +import ConfirmationModal from './confirmation_modal.vue'; export default { name: 'IntegrationForm', @@ -20,8 +22,12 @@ export default { JiraIssuesFields, TriggerFields, DynamicField, + ConfirmationModal, GlButton, }, + directives: { + 'gl-modal': GlModalDirective, + }, mixins: [glFeatureFlagsMixin()], computed: { ...mapGetters(['currentKey', 'propsSource', 'isSavingOrTesting']), @@ -32,6 +38,9 @@ export default { isJira() { return this.propsSource.type === 'jira'; }, + isInstanceLevel() { + return this.propsSource.integrationLevel === integrationLevels.INSTANCE; + }, showJiraIssuesFields() { return this.isJira && this.glFeatures.jiraIssuesIntegration; }, @@ -82,7 +91,21 @@ export default { v-bind="propsSource.jiraIssuesProps" /> <div v-if="isEditable" class="footer-block row-content-block"> + <template v-if="isInstanceLevel"> + <gl-button + v-gl-modal.confirmSaveIntegration + category="primary" + variant="success" + :loading="isSaving" + :disabled="isSavingOrTesting" + data-qa-selector="save_changes_button" + > + {{ __('Save changes') }} + </gl-button> + <confirmation-modal @submit="onSaveClick" /> + </template> <gl-button + v-else category="primary" variant="success" type="submit" @@ -93,6 +116,7 @@ export default { > {{ __('Save changes') }} </gl-button> + <gl-button v-if="propsSource.canTest" :loading="isTesting" diff --git a/app/assets/javascripts/invite_member/components/invite_member_modal.vue b/app/assets/javascripts/invite_member/components/invite_member_modal.vue new file mode 100644 index 00000000000..3df99bccdb0 --- /dev/null +++ b/app/assets/javascripts/invite_member/components/invite_member_modal.vue @@ -0,0 +1,64 @@ +<script> +import { GlModal, GlLink } from '@gitlab/ui'; +import eventHub from '../event_hub'; +import { s__, __ } from '~/locale'; +import { OPEN_MODAL, MODAL_ID } from '../constants'; + +export default { + cancelProps: { + text: __('Got it'), + attributes: [ + { + variant: 'info', + }, + ], + }, + modalId: MODAL_ID, + components: { + GlLink, + GlModal, + }, + inject: { + membersPath: { + default: '', + }, + }, + i18n: { + modalTitle: s__("InviteMember|Oops, this feature isn't ready yet"), + bodyTopMessage: s__( + "InviteMember|We're working to allow everyone to invite new members, making it easier for teams to get started with GitLab", + ), + bodyMiddleMessage: s__( + 'InviteMember|Until then, ask an owner to invite new project members for you', + ), + linkText: s__('InviteMember|See who can invite members for you'), + }, + mounted() { + eventHub.$on(OPEN_MODAL, this.openModal); + }, + methods: { + openModal() { + this.$root.$emit('bv::show::modal', MODAL_ID); + }, + }, +}; +</script> +<template> + <gl-modal :modal-id="$options.modalId" size="sm" :action-cancel="$options.cancelProps"> + <template #modal-title> + {{ $options.i18n.modalTitle }} + <gl-emoji + class="gl-vertical-align-baseline font-size-inherit gl-mr-1" + data-name="sweat_smile" + /> + </template> + <p>{{ $options.i18n.bodyTopMessage }}</p> + <p>{{ $options.i18n.bodyMiddleMessage }}</p> + <gl-link + :href="membersPath" + data-track-event="click_who_can_invite_link" + data-track-label="invite_members_message" + >{{ $options.i18n.linkText }}</gl-link + > + </gl-modal> +</template> diff --git a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue new file mode 100644 index 00000000000..6e886e0e002 --- /dev/null +++ b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue @@ -0,0 +1,37 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import eventHub from '../event_hub'; +import { OPEN_MODAL } from '../constants'; + +export default { + components: { + GlLink, + }, + inject: { + displayText: { + default: '', + }, + event: { + default: '', + }, + label: { + default: '', + }, + }, + methods: { + openModal() { + eventHub.$emit(OPEN_MODAL); + }, + }, +}; +</script> + +<template> + <gl-link + data-is-link="true" + :data-track-event="event" + :data-track-label="label" + @click="openModal" + >{{ displayText }} + </gl-link> +</template> diff --git a/app/assets/javascripts/invite_member/constants.js b/app/assets/javascripts/invite_member/constants.js new file mode 100644 index 00000000000..fee6e7a260a --- /dev/null +++ b/app/assets/javascripts/invite_member/constants.js @@ -0,0 +1,2 @@ +export const OPEN_MODAL = 'openModal'; +export const MODAL_ID = 'invite-member-modal'; diff --git a/app/assets/javascripts/invite_member/event_hub.js b/app/assets/javascripts/invite_member/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/invite_member/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/invite_member/init_invite_member_modal.js b/app/assets/javascripts/invite_member/init_invite_member_modal.js new file mode 100644 index 00000000000..7d60d78d3d9 --- /dev/null +++ b/app/assets/javascripts/invite_member/init_invite_member_modal.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import InviteMemberModal from './components/invite_member_modal.vue'; + +Vue.use(GlToast); + +export default function initInviteMembersModal() { + const el = document.querySelector('.js-invite-member-modal'); + + if (!el) { + return false; + } + + const { membersPath } = el.dataset; + + return new Vue({ + el, + provide: { membersPath }, + render: createElement => createElement(InviteMemberModal), + }); +} diff --git a/app/assets/javascripts/invite_member/init_invite_member_trigger.js b/app/assets/javascripts/invite_member/init_invite_member_trigger.js new file mode 100644 index 00000000000..a5f904b87a6 --- /dev/null +++ b/app/assets/javascripts/invite_member/init_invite_member_trigger.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import InviteMemberTrigger from './components/invite_member_trigger.vue'; + +export default function initInviteMembersTrigger() { + const el = document.querySelector('.js-invite-member-trigger'); + + if (!el) { + return false; + } + + return new Vue({ + el, + provide: { ...el.dataset }, + render: createElement => createElement(InviteMemberTrigger), + }); +} diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue new file mode 100644 index 00000000000..d2ea14a658b --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -0,0 +1,224 @@ +<script> +import { + GlModal, + GlDropdown, + GlDropdownItem, + GlDatepicker, + GlLink, + GlSprintf, + GlSearchBoxByType, + GlButton, + GlFormInput, +} from '@gitlab/ui'; +import eventHub from '../event_hub'; +import { s__, sprintf } from '~/locale'; +import Api from '~/api'; + +export default { + name: 'InviteMembersModal', + components: { + GlDatepicker, + GlLink, + GlModal, + GlDropdown, + GlDropdownItem, + GlSprintf, + GlSearchBoxByType, + GlButton, + GlFormInput, + }, + props: { + groupId: { + type: String, + required: true, + }, + groupName: { + type: String, + required: true, + }, + accessLevels: { + type: Object, + required: true, + }, + defaultAccessLevel: { + type: String, + required: true, + }, + helpLink: { + type: String, + required: true, + }, + }, + data() { + return { + visible: true, + modalId: 'invite-members-modal', + selectedAccessLevel: this.defaultAccessLevel, + newUsersToInvite: '', + selectedDate: undefined, + }; + }, + computed: { + introText() { + return sprintf(s__("InviteMembersModal|You're inviting members to the %{group_name} group"), { + group_name: this.groupName, + }); + }, + toastOptions() { + return { + onComplete: () => { + this.selectedAccessLevel = this.defaultAccessLevel; + this.newUsersToInvite = ''; + }, + }; + }, + postData() { + return { + user_id: this.newUsersToInvite, + access_level: this.selectedAccessLevel, + expires_at: this.selectedDate, + format: 'json', + }; + }, + selectedRoleName() { + return Object.keys(this.accessLevels).find( + key => this.accessLevels[key] === Number(this.selectedAccessLevel), + ); + }, + }, + mounted() { + eventHub.$on('openModal', this.openModal); + }, + methods: { + openModal() { + this.$root.$emit('bv::show::modal', this.modalId); + }, + closeModal() { + this.$root.$emit('bv::hide::modal', this.modalId); + }, + sendInvite() { + this.submitForm(this.postData); + this.closeModal(); + }, + cancelInvite() { + this.selectedAccessLevel = this.defaultAccessLevel; + this.selectedDate = undefined; + this.newUsersToInvite = ''; + this.closeModal(); + }, + changeSelectedItem(item) { + this.selectedAccessLevel = item; + }, + submitForm(formData) { + return Api.inviteGroupMember(this.groupId, formData) + .then(() => { + this.showToastMessageSuccess(); + }) + .catch(error => { + this.showToastMessageError(error); + }); + }, + showToastMessageSuccess() { + this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); + }, + showToastMessageError(error) { + const message = error.response.data.message || this.$options.labels.toastMessageUnsuccessful; + + this.$toast.show(message, this.toastOptions); + }, + }, + labels: { + modalTitle: s__('InviteMembersModal|Invite team members'), + userToInvite: s__('InviteMembersModal|GitLab member or Email address'), + userPlaceholder: s__('InviteMembersModal|Search for members to invite'), + accessLevel: s__('InviteMembersModal|Choose a role permission'), + accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), + toastMessageSuccessful: s__('InviteMembersModal|Users were succesfully added'), + toastMessageUnsuccessful: s__('InviteMembersModal|User not invited. Feature coming soon!'), + readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`), + inviteButtonText: s__('InviteMembersModal|Invite'), + cancelButtonText: s__('InviteMembersModal|Cancel'), + }, +}; +</script> +<template> + <gl-modal :modal-id="modalId" size="sm" :title="$options.labels.modalTitle"> + <div class="gl-ml-5 gl-mr-5"> + <div>{{ introText }}</div> + + <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.userToInvite }}</label> + <div class="gl-mt-2"> + <gl-search-box-by-type + v-model="newUsersToInvite" + :placeholder="$options.labels.userPlaceholder" + type="text" + autocomplete="off" + autocorrect="off" + autocapitalize="off" + spellcheck="false" + /> + </div> + + <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label> + <div class="gl-mt-2 gl-w-half gl-xs-w-full"> + <gl-dropdown + menu-class="dropdown-menu-selectable" + class="gl-shadow-none gl-w-full" + v-bind="$attrs" + :text="selectedRoleName" + > + <template v-for="(key, item) in accessLevels"> + <gl-dropdown-item + :key="key" + active-class="is-active" + :is-checked="key === selectedAccessLevel" + @click="changeSelectedItem(key)" + > + <div>{{ item }}</div> + </gl-dropdown-item> + </template> + </gl-dropdown> + </div> + + <div class="gl-mt-2"> + <gl-sprintf :message="$options.labels.readMoreText"> + <template #link="{content}"> + <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + + <label class="gl-font-weight-bold gl-mt-5" for="expires_at">{{ + $options.labels.accessExpireDate + }}</label> + <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> + <gl-datepicker + v-model="selectedDate" + class="gl-display-inline!" + :min-date="new Date()" + :target="null" + > + <template #default="{ formattedDate }"> + <gl-form-input + class="gl-w-full" + :value="formattedDate" + :placeholder="__(`YYYY-MM-DD`)" + /> + </template> + </gl-datepicker> + </div> + </div> + + <template #modal-footer> + <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-p-3"> + <gl-button ref="cancelButton" @click="cancelInvite"> + {{ $options.labels.cancelButtonText }} + </gl-button> + <div class="gl-mr-3"></div> + <gl-button ref="inviteButton" variant="success" @click="sendInvite">{{ + $options.labels.inviteButtonText + }}</gl-button> + </div> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue new file mode 100644 index 00000000000..d133e3655e3 --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -0,0 +1,38 @@ +<script> +import { GlLink, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + components: { + GlLink, + GlIcon, + }, + props: { + displayText: { + type: String, + required: false, + default: s__('InviteMembers|Invite team members'), + }, + icon: { + type: String, + required: false, + default: '', + }, + }, + methods: { + openModal() { + eventHub.$emit('openModal'); + }, + }, +}; +</script> + +<template> + <gl-link @click="openModal"> + <div v-if="icon" class="nav-icon-container"> + <gl-icon :size="16" :name="icon" /> + </div> + <span class="nav-item-name"> {{ displayText }} </span> + </gl-link> +</template> diff --git a/app/assets/javascripts/invite_members/event_hub.js b/app/assets/javascripts/invite_members/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/invite_members/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js new file mode 100644 index 00000000000..92aa3187fc3 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; + +Vue.use(GlToast); + +export default function initInviteMembersModal() { + const el = document.querySelector('.js-invite-members-modal'); + + if (!el) { + return false; + } + + return new Vue({ + el, + render: createElement => + createElement(InviteMembersModal, { + props: { + ...el.dataset, + accessLevels: JSON.parse(el.dataset.accessLevels), + groupName: el.dataset.groupName.toUpperCase(), + }, + }), + }); +} diff --git a/app/assets/javascripts/invite_members/init_invite_members_trigger.js b/app/assets/javascripts/invite_members/init_invite_members_trigger.js new file mode 100644 index 00000000000..bee4f1c0f72 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_invite_members_trigger.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; + +export default function initInviteMembersTrigger() { + const el = document.querySelector('.js-invite-members-trigger'); + + if (!el) { + return false; + } + + return new Vue({ + el, + render: createElement => + createElement(InviteMembersTrigger, { + props: { + ...el.dataset, + }, + }), + }); +} diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 566efa0d7d6..6f2bd2da078 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -6,6 +6,7 @@ import UsersSelect from './users_select'; export default class IssuableContext { constructor(currentUser) { this.userSelect = new UsersSelect(currentUser); + this.reviewersSelect = new UsersSelect(currentUser, '.js-reviewer-search'); import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { diff --git a/app/assets/javascripts/issuable_create/components/issuable_form.vue b/app/assets/javascripts/issuable_create/components/issuable_form.vue index 17e51b3dbac..d7b88cc7fc8 100644 --- a/app/assets/javascripts/issuable_create/components/issuable_form.vue +++ b/app/assets/javascripts/issuable_create/components/issuable_form.vue @@ -71,6 +71,7 @@ export default { :markdown-docs-path="descriptionHelpPath" :add-spacing-classes="false" :show-suggest-popover="true" + :textarea-value="issuableDescription" > <textarea id="issuable-description" diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/issuable_show/components/issuable_body.vue new file mode 100644 index 00000000000..e6a05c1ab8b --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_body.vue @@ -0,0 +1,103 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +import IssuableTitle from './issuable_title.vue'; +import IssuableDescription from './issuable_description.vue'; +import IssuableEditForm from './issuable_edit_form.vue'; + +export default { + components: { + GlLink, + TimeAgoTooltip, + IssuableTitle, + IssuableDescription, + IssuableEditForm, + }, + props: { + issuable: { + type: Object, + required: true, + }, + statusBadgeClass: { + type: String, + required: true, + }, + statusIcon: { + type: String, + required: true, + }, + enableEdit: { + type: Boolean, + required: true, + }, + enableAutocomplete: { + type: Boolean, + required: true, + }, + editFormVisible: { + type: Boolean, + required: true, + }, + descriptionPreviewPath: { + type: String, + required: true, + }, + descriptionHelpPath: { + type: String, + required: true, + }, + }, + computed: { + isUpdated() { + return Boolean(this.issuable.updatedAt); + }, + updatedBy() { + return this.issuable.updatedBy; + }, + }, +}; +</script> + +<template> + <div class="issue-details issuable-details"> + <div class="detail-page-description content-block"> + <issuable-edit-form + v-if="editFormVisible" + :issuable="issuable" + :enable-autocomplete="enableAutocomplete" + :description-preview-path="descriptionPreviewPath" + :description-help-path="descriptionHelpPath" + > + <template #edit-form-actions="issuableMeta"> + <slot name="edit-form-actions" v-bind="issuableMeta"></slot> + </template> + </issuable-edit-form> + <template v-else> + <issuable-title + :issuable="issuable" + :status-badge-class="statusBadgeClass" + :status-icon="statusIcon" + :enable-edit="enableEdit" + @edit-issuable="$emit('edit-issuable', $event)" + > + <template #status-badge> + <slot name="status-badge"></slot> + </template> + </issuable-title> + <issuable-description v-if="issuable.descriptionHtml" :issuable="issuable" /> + <small v-if="isUpdated" class="edited-text gl-font-sm!"> + {{ __('Edited') }} + <time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" /> + <span v-if="updatedBy"> + {{ __('by') }} + <gl-link :href="updatedBy.webUrl" class="author-link gl-font-sm!"> + <span>{{ updatedBy.name }}</span> + </gl-link> + </span> + </small> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issuable_show/components/issuable_description.vue b/app/assets/javascripts/issuable_show/components/issuable_description.vue new file mode 100644 index 00000000000..091a4be5bd8 --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_description.vue @@ -0,0 +1,31 @@ +<script> +import $ from 'jquery'; +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import '~/behaviors/markdown/render_gfm'; + +export default { + directives: { + SafeHtml, + }, + props: { + issuable: { + type: Object, + required: true, + }, + }, + mounted() { + this.renderGFM(); + }, + methods: { + renderGFM() { + $(this.$refs.gfmContainer).renderGFM(); + }, + }, +}; +</script> + +<template> + <div class="description"> + <div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div> + </div> +</template> diff --git a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue new file mode 100644 index 00000000000..7b9a83a740f --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue @@ -0,0 +1,135 @@ +<script> +import $ from 'jquery'; +import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; + +import Autosave from '~/autosave'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; + +import eventHub from '../event_hub'; + +export default { + components: { + GlForm, + GlFormGroup, + GlFormInput, + MarkdownField, + }, + props: { + issuable: { + type: Object, + required: true, + }, + enableAutocomplete: { + type: Boolean, + required: true, + }, + descriptionPreviewPath: { + type: String, + required: true, + }, + descriptionHelpPath: { + type: String, + required: true, + }, + }, + data() { + const { title, description } = this.issuable; + + return { + title, + description, + }; + }, + created() { + eventHub.$on('update.issuable', this.resetAutosave); + eventHub.$on('close.form', this.resetAutosave); + }, + mounted() { + this.initAutosave(); + }, + beforeDestroy() { + eventHub.$off('update.issuable', this.resetAutosave); + eventHub.$off('close.form', this.resetAutosave); + }, + methods: { + initAutosave() { + const { titleInput, descriptionInput } = this.$refs; + + if (!titleInput || !descriptionInput) return; + + this.autosaveTitle = new Autosave($(titleInput.$el), [ + document.location.pathname, + document.location.search, + 'title', + ]); + + this.autosaveDescription = new Autosave($(descriptionInput.$el), [ + document.location.pathname, + document.location.search, + 'description', + ]); + }, + resetAutosave() { + this.autosaveTitle.reset(); + this.autosaveDescription.reset(); + }, + }, +}; +</script> + +<template> + <gl-form> + <gl-form-group + data-testid="title" + :label="__('Title')" + :label-sr-only="true" + label-for="issuable-title" + class="col-12" + > + <gl-form-input + id="issuable-title" + ref="titleInput" + v-model.trim="title" + :placeholder="__('Title')" + :aria-label="__('Title')" + :autofocus="true" + class="qa-title-input" + /> + </gl-form-group> + <gl-form-group + data-testid="description" + :label="__('Description')" + :label-sr-only="true" + label-for="issuable-description" + class="col-12 common-note-form" + > + <markdown-field + :markdown-preview-path="descriptionPreviewPath" + :markdown-docs-path="descriptionHelpPath" + :enable-autocomplete="enableAutocomplete" + :textarea-value="description" + > + <template #textarea> + <textarea + id="issuable-description" + ref="descriptionInput" + v-model="description" + :data-supports-quick-actions="enableAutocomplete" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files here…')" + class="note-textarea js-gfm-input js-autosize markdown-area + qa-description-textarea" + dir="auto" + ></textarea> + </template> + </markdown-field> + </gl-form-group> + <div data-testid="actions" class="col-12 gl-mt-3 gl-mb-3 clearfix"> + <slot + name="edit-form-actions" + :issuable-title="title" + :issuable-description="description" + ></slot> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/issuable_show/components/issuable_header.vue b/app/assets/javascripts/issuable_show/components/issuable_header.vue new file mode 100644 index 00000000000..3815c50cac6 --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_header.vue @@ -0,0 +1,120 @@ +<script> +import { GlIcon, GlButton, GlTooltipDirective, GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui'; + +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + GlIcon, + GlButton, + GlAvatarLink, + GlAvatarLabeled, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + createdAt: { + type: String, + required: true, + }, + author: { + type: Object, + required: true, + }, + statusBadgeClass: { + type: String, + required: false, + default: '', + }, + statusIcon: { + type: String, + required: false, + default: '', + }, + blocked: { + type: Boolean, + required: false, + default: false, + }, + confidential: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + authorId() { + return getIdFromGraphQLId(`${this.author.id}`); + }, + }, + mounted() { + this.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button'); + }, + methods: { + handleRightSidebarToggleClick() { + if (this.toggleSidebarButtonEl) { + this.toggleSidebarButtonEl.dispatchEvent(new Event('click')); + } + }, + }, +}; +</script> + +<template> + <div class="detail-page-header"> + <div class="detail-page-header-body"> + <div data-testid="status" class="issuable-status-box status-box" :class="statusBadgeClass"> + <gl-icon v-if="statusIcon" :name="statusIcon" class="d-block d-sm-none" /> + <span class="d-none d-sm-block"><slot name="status-badge"></slot></span> + </div> + <div class="issuable-meta gl-display-flex gl-align-items-center"> + <div class="gl-display-inline-block"> + <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline"> + <gl-icon name="lock" :aria-label="__('Blocked')" /> + </div> + <div v-if="confidential" data-testid="confidential" class="issuable-warning-icon inline"> + <gl-icon name="eye-slash" :aria-label="__('Confidential')" /> + </div> + </div> + <span> + {{ __('Opened') }} + <time-ago-tooltip data-testid="startTimeItem" :time="createdAt" /> + {{ __('by') }} + </span> + <gl-avatar-link + data-testid="avatar" + :data-user-id="authorId" + :data-username="author.username" + :data-name="author.name" + :href="author.webUrl" + target="_blank" + class="js-user-link gl-ml-2" + > + <gl-avatar-labeled + :size="24" + :src="author.avatarUrl" + :label="author.name" + class="d-none d-sm-inline-flex gl-ml-1" + /> + <strong class="author d-sm-none d-inline">@{{ author.username }}</strong> + </gl-avatar-link> + </div> + <gl-button + data-testid="sidebar-toggle" + icon="chevron-double-lg-left" + class="d-block d-sm-none gutter-toggle issuable-gutter-toggle" + :aria-label="__('Expand sidebar')" + @click="handleRightSidebarToggleClick" + /> + </div> + <div + data-testid="header-actions" + class="detail-page-header-actions js-issuable-actions js-issuable-buttons gl-display-flex gl-display-md-block" + > + <slot name="header-actions"></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue new file mode 100644 index 00000000000..b41f5e270a8 --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue @@ -0,0 +1,98 @@ +<script> +import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue'; + +import IssuableHeader from './issuable_header.vue'; +import IssuableBody from './issuable_body.vue'; + +export default { + components: { + IssuableSidebar, + IssuableHeader, + IssuableBody, + }, + props: { + issuable: { + type: Object, + required: true, + }, + statusBadgeClass: { + type: String, + required: false, + default: '', + }, + statusIcon: { + type: String, + required: false, + default: '', + }, + enableEdit: { + type: Boolean, + required: false, + default: false, + }, + enableAutocomplete: { + type: Boolean, + required: false, + default: false, + }, + editFormVisible: { + type: Boolean, + required: false, + default: false, + }, + descriptionPreviewPath: { + type: String, + required: false, + default: '', + }, + descriptionHelpPath: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> + +<template> + <div class="issuable-show-container"> + <issuable-header + :status-badge-class="statusBadgeClass" + :status-icon="statusIcon" + :blocked="issuable.blocked" + :confidential="issuable.confidential" + :created-at="issuable.createdAt" + :author="issuable.author" + > + <template #status-badge> + <slot name="status-badge"></slot> + </template> + <template #header-actions> + <slot name="header-actions"></slot> + </template> + </issuable-header> + <issuable-body + :issuable="issuable" + :status-badge-class="statusBadgeClass" + :status-icon="statusIcon" + :enable-edit="enableEdit" + :enable-autocomplete="enableAutocomplete" + :edit-form-visible="editFormVisible" + :description-preview-path="descriptionPreviewPath" + :description-help-path="descriptionHelpPath" + @edit-issuable="$emit('edit-issuable', $event)" + > + <template #status-badge> + <slot name="status-badge"></slot> + </template> + <template #edit-form-actions="actionsProps"> + <slot name="edit-form-actions" v-bind="actionsProps"></slot> + </template> + </issuable-body> + <issuable-sidebar @sidebar-toggle="$emit('sidebar-toggle', $event)"> + <template #right-sidebar-items="sidebarProps"> + <slot name="right-sidebar-items" v-bind="sidebarProps"></slot> + </template> + </issuable-sidebar> + </div> +</template> diff --git a/app/assets/javascripts/issuable_show/components/issuable_title.vue b/app/assets/javascripts/issuable_show/components/issuable_title.vue new file mode 100644 index 00000000000..d3b42fd2ffb --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_title.vue @@ -0,0 +1,96 @@ +<script> +import { + GlIcon, + GlButton, + GlIntersectionObserver, + GlTooltipDirective, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlButton, + GlIntersectionObserver, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + props: { + issuable: { + type: Object, + required: true, + }, + statusBadgeClass: { + type: String, + required: true, + }, + statusIcon: { + type: String, + required: true, + }, + enableEdit: { + type: Boolean, + required: true, + }, + }, + data() { + return { + stickyTitleVisible: false, + }; + }, + methods: { + handleTitleAppear() { + this.stickyTitleVisible = false; + }, + handleTitleDisappear() { + this.stickyTitleVisible = true; + }, + }, +}; +</script> + +<template> + <div> + <div class="title-container"> + <h2 v-safe-html="issuable.titleHtml" class="title qa-title" dir="auto"></h2> + <gl-button + v-if="enableEdit" + v-gl-tooltip.bottom + :title="__('Edit title and description')" + icon="pencil" + class="btn-edit js-issuable-edit qa-edit-button" + @click="$emit('edit-issuable', $event)" + /> + </div> + <gl-intersection-observer @appear="handleTitleAppear" @disappear="handleTitleDisappear"> + <transition name="issuable-header-slide"> + <div + v-if="stickyTitleVisible" + 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="header" + > + <div + class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5" + > + <p + data-testid="status" + class="issuable-status-box status-box gl-my-0" + :class="statusBadgeClass" + > + <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" /> + <span class="gl-display-none d-sm-block"><slot name="status-badge"></slot></span> + </p> + <p + class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0" + :title="issuable.title" + > + {{ issuable.title }} + </p> + </div> + </div> + </transition> + </gl-intersection-observer> + </div> +</template> diff --git a/app/assets/javascripts/issuable_show/constants.js b/app/assets/javascripts/issuable_show/constants.js new file mode 100644 index 00000000000..346f45c7d90 --- /dev/null +++ b/app/assets/javascripts/issuable_show/constants.js @@ -0,0 +1,5 @@ +export const IssuableType = { + Issue: 'issue', + Incident: 'incident', + TestCase: 'test_case', +}; diff --git a/app/assets/javascripts/issuable_show/event_hub.js b/app/assets/javascripts/issuable_show/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/issuable_show/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue new file mode 100644 index 00000000000..7d1339f833d --- /dev/null +++ b/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue @@ -0,0 +1,88 @@ +<script> +import Cookies from 'js-cookie'; +import { GlIcon } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; + +import { parseBoolean } from '~/lib/utils/common_utils'; + +export default { + components: { + GlIcon, + }, + data() { + const userExpanded = !parseBoolean(Cookies.get('collapsed_gutter')); + + // We're deliberately keeping two different props for sidebar status; + // 1. userExpanded reflects value based on cookie `collapsed_gutter`. + // 2. isExpanded reflect actual sidebar state. + return { + userExpanded, + isExpanded: userExpanded ? bp.isDesktop() : userExpanded, + }; + }, + watch: { + isExpanded(expanded) { + this.$emit('sidebar-toggle', { + expanded, + }); + }, + }, + mounted() { + window.addEventListener('resize', this.handleWindowResize); + }, + beforeDestroy() { + window.removeEventListener('resize', this.handleWindowResize); + }, + methods: { + updatePageContainerClass() { + const layoutPageEl = document.querySelector('.layout-page'); + + if (layoutPageEl) { + layoutPageEl.classList.toggle('right-sidebar-expanded', this.isExpanded); + layoutPageEl.classList.toggle('right-sidebar-collapsed', !this.isExpanded); + } + }, + handleWindowResize() { + if (this.userExpanded) { + this.isExpanded = bp.isDesktop(); + this.updatePageContainerClass(); + } + }, + handleToggleSidebarClick() { + this.isExpanded = !this.isExpanded; + this.userExpanded = this.isExpanded; + + Cookies.set('collapsed_gutter', !this.userExpanded); + this.updatePageContainerClass(); + }, + }, +}; +</script> + +<template> + <aside + :class="{ 'right-sidebar-expanded': isExpanded, 'right-sidebar-collapsed': !isExpanded }" + class="right-sidebar" + aria-live="polite" + > + <button + class="toggle-right-sidebar-button js-toggle-right-sidebar-button w-100 gl-text-decoration-none! gl-display-flex gl-outline-0!" + :title="__('Toggle sidebar')" + @click="handleToggleSidebarClick" + > + <span v-if="isExpanded" class="collapse-text gl-flex-grow-1 gl-text-left">{{ + __('Collapse sidebar') + }}</span> + <gl-icon v-show="isExpanded" data-testid="icon-collapse" name="chevron-double-lg-right" /> + <gl-icon + v-show="!isExpanded" + data-testid="icon-expand" + name="chevron-double-lg-left" + class="gl-ml-2" + /> + </button> + <div data-testid="sidebar-items" class="issuable-sidebar"> + <slot name="right-sidebar-items" v-bind="{ sidebarExpanded: isExpanded }"></slot> + </div> + </aside> +</template> diff --git a/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue b/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue deleted file mode 100644 index 06c50f62aab..00000000000 --- a/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue +++ /dev/null @@ -1,23 +0,0 @@ -<script> -export default { - props: { - signedIn: { - type: Boolean, - required: true, - }, - sidebarStatusClass: { - type: String, - required: false, - default: '', - }, - }, -}; -</script> - -<template> - <aside - :class="sidebarStatusClass" - class="right-sidebar js-right-sidebar js-issuable-sidebar" - aria-live="polite" - ></aside> -</template> diff --git a/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js b/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js deleted file mode 100644 index c8acafa8cd8..00000000000 --- a/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js +++ /dev/null @@ -1,27 +0,0 @@ -import Vue from 'vue'; - -import SidebarApp from './components/sidebar_app.vue'; - -export default () => { - const el = document.getElementById('js-vue-issuable-sidebar'); - - if (!el) { - return false; - } - - const { sidebarStatusClass } = el.dataset; - // An empty string is present when user is signed in. - const signedIn = el.dataset.signedIn === ''; - - return new Vue({ - el, - components: { SidebarApp }, - render: createElement => - createElement('sidebar-app', { - props: { - signedIn, - sidebarStatusClass, - }, - }), - }); -}; diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue index e1b308c6f57..8a1a8448bb8 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ import $ from 'jquery'; import { GlIcon } from '@gitlab/ui'; import IssuableTemplateSelectors from '../../../templates/issuable_template_selectors'; @@ -62,11 +61,15 @@ export default { data-toggle="dropdown" > <span class="dropdown-toggle-text">{{ __('Choose a template') }}</span> - <i aria-hidden="true" class="fa fa-chevron-down"> </i> + <gl-icon + name="chevron-down" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + aria-hidden="true" + /> </button> <div class="dropdown-menu dropdown-select"> <div class="dropdown-title gl-display-flex gl-justify-content-center"> - <span class="gl-ml-auto">Choose a template</span> + <span class="gl-ml-auto">{{ __('Choose a template') }}</span> <button class="dropdown-title-button dropdown-menu-close gl-ml-auto" :aria-label="__('Close')" @@ -82,7 +85,7 @@ export default { :placeholder="__('Filter')" autocomplete="off" /> - <i aria-hidden="true" class="fa fa-search dropdown-input-search"> </i> + <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" /> <gl-icon name="close" class="dropdown-input-clear js-dropdown-input-clear" diff --git a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql b/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql index 00ddc80432d..bb637dea033 100644 --- a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql +++ b/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql @@ -13,6 +13,7 @@ query getAlert($iid: String!, $fullPath: ID!) { service description endedAt + hosts details } } diff --git a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue b/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue index a47fe4c84cf..96f187f26dd 100644 --- a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue +++ b/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue @@ -1,42 +1,63 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import { formatDate } from '~/lib/utils/datetime_utility'; export default { components: { GlLink, + IncidentSla: () => import('ee_component/issue_show/components/incidents/incident_sla.vue'), + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { alert: { type: Object, - required: true, + required: false, + default: null, }, }, + data() { + return { childHasData: false }; + }, computed: { startTime() { return formatDate(this.alert.startedAt, 'yyyy-mm-dd Z'); }, + showHighlightBar() { + return this.alert || this.childHasData; + }, + }, + methods: { + update(hasData) { + this.childHasData = hasData; + }, }, }; </script> <template> <div - class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between" + v-show="showHighlightBar" + class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column" > - <div class="text-truncate gl-pr-3"> + <div v-if="alert" class="gl-mr-3"> <span class="gl-font-weight-bold">{{ s__('HighlightBar|Original alert:') }}</span> - <gl-link :href="alert.detailsUrl">{{ alert.title }}</gl-link> + <gl-link v-gl-tooltip :title="alert.title" :href="alert.detailsUrl"> + #{{ alert.iid }} + </gl-link> </div> - <div class="gl-pr-3 gl-white-space-nowrap"> + <div v-if="alert" class="gl-mr-3"> <span class="gl-font-weight-bold">{{ s__('HighlightBar|Alert start time:') }}</span> {{ startTime }} </div> - <div class="gl-white-space-nowrap"> + <div v-if="alert" class="gl-mr-3"> <span class="gl-font-weight-bold">{{ s__('HighlightBar|Alert events:') }}</span> <span>{{ alert.eventCount }}</span> </div> + + <incident-sla @update="update" /> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue index 4104ddbf06f..c593fa33973 100644 --- a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue @@ -5,8 +5,10 @@ import HighlightBar from './highlight_bar.vue'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import Tracking from '~/tracking'; import getAlert from './graphql/queries/get_alert.graphql'; +import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; export default { components: { @@ -45,12 +47,14 @@ export default { loading() { return this.$apollo.queries.alert.loading; }, - alertTableFields() { - if (this.alert) { - const { detailsUrl, __typename, ...restDetails } = this.alert; - return restDetails; - } - return null; + }, + mounted() { + this.trackPageViews(); + }, + methods: { + trackPageViews() { + const { category, action } = trackIncidentDetailsViewsOptions; + Tracking.event(category, action); }, }, }; @@ -60,11 +64,11 @@ export default { <div> <gl-tabs content-class="gl-reset-line-height" class="gl-mt-n3" data-testid="incident-tabs"> <gl-tab :title="s__('Incident|Summary')"> - <highlight-bar v-if="alert" :alert="alert" /> + <highlight-bar :alert="alert" /> <description-component v-bind="$attrs" /> </gl-tab> <gl-tab v-if="alert" class="alert-management-details" :title="s__('Incident|Alert details')"> - <alert-details-table :alert="alertTableFields" :loading="loading" /> + <alert-details-table :alert="alert" :loading="loading" /> </gl-tab> </gl-tabs> </div> diff --git a/app/assets/javascripts/issue_show/incident.js b/app/assets/javascripts/issue_show/incident.js index a34e75ee64a..618fb551f28 100644 --- a/app/assets/javascripts/issue_show/incident.js +++ b/app/assets/javascripts/issue_show/incident.js @@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import issuableApp from './components/app.vue'; import incidentTabs from './components/incidents/incident_tabs.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; Vue.use(VueApollo); @@ -11,7 +12,7 @@ export default function initIssuableApp(issuableData = {}) { defaultClient: createDefaultClient(), }); - const { projectNamespace, projectPath, iid } = issuableData; + const { iid, projectNamespace, projectPath, slaFeatureAvailable } = issuableData; return new Vue({ el: document.getElementById('js-issuable-app'), @@ -22,6 +23,7 @@ export default function initIssuableApp(issuableData = {}) { provide: { fullPath: `${projectNamespace}/${projectPath}`, iid, + slaFeatureAvailable: parseBoolean(slaFeatureAvailable), }, render(createElement) { return createElement('issuable-app', { diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index c6f7e892f9b..06bbd406e3a 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -1,4 +1,4 @@ -import { sanitize } from 'dompurify'; +import { sanitize } from '~/lib/dompurify'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import updateDescription from '../utils/update_description'; diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js index a62a5167961..620974901fb 100644 --- a/app/assets/javascripts/issue_show/utils/parse_data.js +++ b/app/assets/javascripts/issue_show/utils/parse_data.js @@ -1,4 +1,5 @@ -import { sanitize } from 'dompurify'; +import * as Sentry from '~/sentry/wrapper'; +import { sanitize } from '~/lib/dompurify'; // We currently load + parse the data from the issue app and related merge request let cachedParsedData; @@ -7,10 +8,9 @@ export const parseIssuableData = () => { try { if (cachedParsedData) return cachedParsedData; - const initialDataEl = document.getElementById('js-issuable-app-initial-data'); - - const parsedData = JSON.parse(initialDataEl.textContent.replace(/"/g, '"')); + const initialDataEl = document.getElementById('js-issuable-app'); + const parsedData = JSON.parse(initialDataEl.dataset.initial); parsedData.initialTitleHtml = sanitize(parsedData.initialTitleHtml); parsedData.initialDescriptionHtml = sanitize(parsedData.initialDescriptionHtml); @@ -18,7 +18,7 @@ export const parseIssuableData = () => { return parsedData; } catch (e) { - console.error(e); // eslint-disable-line no-console + Sentry.captureException(e); return {}; } diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue index adfb234fe7a..dc63d613b5b 100644 --- a/app/assets/javascripts/issues_list/components/issuable.vue +++ b/app/assets/javascripts/issues_list/components/issuable.vue @@ -351,7 +351,7 @@ export default { :class="{ cred: isOverdue }" :title="__('Due date')" > - <i class="fa fa-calendar"></i> + <gl-icon name="calendar" /> {{ dueDateWords }} </span> diff --git a/app/assets/javascripts/jira_connect.js b/app/assets/javascripts/jira_connect.js index 895cdc4562c..0864a3024ac 100644 --- a/app/assets/javascripts/jira_connect.js +++ b/app/assets/javascripts/jira_connect.js @@ -18,6 +18,13 @@ function onLoaded() { alert(res.responseJSON.error); }; + AP.getLocation(function(location) { + $('.js-jira-connect-sign-in').each(function() { + var updatedLink = `${$(this).attr('href')}?return_to=${location}`; + $(this).attr('href', updatedLink); + }); + }); + $('#add-subscription-form').on('submit', function(e) { var actionUrl = $(this).attr('action'); e.preventDefault(); 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 4339021d9a0..4a1bca110fd 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -301,7 +301,7 @@ export default { " @hide="resetDropdown" > - <gl-search-box-by-type v-model.trim="searchTerm" class="gl-m-3" /> + <gl-search-box-by-type v-model.trim="searchTerm" /> <gl-loading-icon v-if="isFetching" /> diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js index 695a237bf50..003f3c7107e 100644 --- a/app/assets/javascripts/jira_import/index.js +++ b/app/assets/javascripts/jira_import/index.js @@ -6,7 +6,7 @@ import App from './components/jira_import_app.vue'; Vue.use(VueApollo); -const defaultClient = createDefaultClient(); +const defaultClient = createDefaultClient({}, { assumeImmutableResults: true }); const apolloProvider = new VueApollo({ defaultClient, diff --git a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql index 8fda8287988..807374bf06c 100644 --- a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql +++ b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql @@ -2,7 +2,6 @@ mutation($input: JiraImportStartInput!) { jiraImportStart(input: $input) { - clientMutationId jiraImport { ...JiraImport } diff --git a/app/assets/javascripts/jira_import/utils/cache_update.js b/app/assets/javascripts/jira_import/utils/cache_update.js index 6aaf2010866..65b2e459f03 100644 --- a/app/assets/javascripts/jira_import/utils/cache_update.js +++ b/app/assets/javascripts/jira_import/utils/cache_update.js @@ -1,3 +1,4 @@ +import produce from 'immer'; import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql'; import { IMPORT_STATE } from './jira_import_utils'; @@ -13,22 +14,20 @@ export const addInProgressImportToStore = (store, jiraImportStart, fullPath) => }, }; - const cacheData = store.readQuery({ + const sourceData = store.readQuery({ ...queryDetails, }); store.writeQuery({ ...queryDetails, - data: { - project: { - ...cacheData.project, - jiraImportStatus: IMPORT_STATE.SCHEDULED, - jiraImports: { - ...cacheData.project.jiraImports, - nodes: cacheData.project.jiraImports.nodes.concat(jiraImportStart.jiraImport), - }, - }, - }, + data: produce(sourceData, draftData => { + draftData.project.jiraImportStatus = IMPORT_STATE.SCHEDULED; // eslint-disable-line no-param-reassign + // eslint-disable-next-line no-param-reassign + draftData.project.jiraImports.nodes = [ + ...sourceData.project.jiraImports.nodes, + jiraImportStart.jiraImport, + ]; + }), }); }; diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue index c4f180f200c..222fae6d9a8 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/commit_block.vue @@ -32,26 +32,25 @@ export default { block: !isLastBlock, }" > - <p class="gl-mb-2"> - <span class="font-weight-bold">{{ __('Commit') }}</span> + <span class="font-weight-bold">{{ __('Commit') }}</span> - <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit"> - {{ commit.short_id }} - </gl-link> + <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit"> + {{ commit.short_id }} + </gl-link> - <clipboard-button - :text="commit.id" - :title="__('Copy commit SHA')" - css-class="btn btn-clipboard btn-transparent" - /> + <clipboard-button + :text="commit.id" + :title="__('Copy commit SHA')" + category="tertiary" + size="small" + /> - <span v-if="mergeRequest"> - in - <gl-link :href="mergeRequest.path" class="js-link-commit link-commit" - >!{{ mergeRequest.iid }}</gl-link - > - </span> - </p> + <span v-if="mergeRequest"> + in + <gl-link :href="mergeRequest.path" class="js-link-commit link-commit" + >!{{ mergeRequest.iid }}</gl-link + > + </span> <p class="gl-mb-0">{{ commit.title }}</p> </div> diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue index 79e6623eca8..6b61dc5902b 100644 --- a/app/assets/javascripts/jobs/components/job_container_item.vue +++ b/app/assets/javascripts/jobs/components/job_container_item.vue @@ -1,6 +1,5 @@ <script> -import { GlLink, GlIcon } from '@gitlab/ui'; -import tooltip from '~/vue_shared/directives/tooltip'; +import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; import { sprintf } from '~/locale'; @@ -12,7 +11,7 @@ export default { GlLink, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, mixins: [delayedJobMixin], props: { @@ -49,10 +48,9 @@ export default { }" > <gl-link - v-tooltip + v-gl-tooltip :href="job.status.details_path" :title="tooltipText" - data-boundary="viewport" class="js-job-link d-flex" > <gl-icon diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue index e68d5b8eda4..791664c05d9 100644 --- a/app/assets/javascripts/jobs/components/log/line.vue +++ b/app/assets/javascripts/jobs/components/log/line.vue @@ -1,6 +1,24 @@ <script> +import linkifyHtml from 'linkifyjs/html'; +import { sanitize } from '~/lib/dompurify'; +import { isAbsolute } from '~/lib/utils/url_utility'; import LineNumber from './line_number.vue'; +const linkifyOptions = { + attributes: { + // eslint-disable-next-line @gitlab/require-i18n-strings + rel: 'nofollow noopener', + }, + className: 'gl-reset-color!', + defaultProtocol: 'https', + validate: { + email: false, + url(value) { + return isAbsolute(value); + }, + }, +}; + export default { functional: true, props: { @@ -17,13 +35,15 @@ export default { const { line, path } = props; const chars = line.content.map(content => { - return h( - 'span', - { - class: ['gl-white-space-pre-wrap', content.style], + const linkfied = linkifyHtml(content.text, linkifyOptions); + return h('span', { + class: ['gl-white-space-pre-wrap', content.style], + domProps: { + innerHTML: sanitize(linkfied, { + ALLOWED_TAGS: ['a'], + }), }, - content.text, - ); + }); }); return h('div', { class: 'js-line log-line' }, [ diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index aa589989e8a..8701e05a01f 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -1,7 +1,7 @@ <script> import { isEmpty } from 'lodash'; import { mapActions, mapState } from 'vuex'; -import { GlLink, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; +import { GlLink, GlButton, GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; @@ -24,7 +24,7 @@ export default { StagesDropdown, JobsContainer, GlLink, - GlDeprecatedButton, + GlButton, TooltipOnTruncate, }, mixins: [timeagoMixin], @@ -143,14 +143,13 @@ export default { > </div> - <gl-deprecated-button + <gl-button :aria-label="__('Toggle Sidebar')" - type="button" - class="btn btn-blank gutter-toggle float-right d-block d-md-none js-sidebar-build-toggle" + class="d-md-none gl-ml-2 js-sidebar-build-toggle" + category="tertiary" + icon="chevron-double-lg-right" @click="toggleSidebar" - > - <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i> - </gl-deprecated-button> + /> </div> <div v-if="job.terminal_path || job.new_issue_path" class="block retry-link"> diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue index f55429ecdae..3cb5e63fd36 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/trigger_block.vue @@ -1,12 +1,12 @@ <script> -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; const HIDDEN_VALUE = '••••••'; export default { components: { - GlDeprecatedButton, + GlButton, }, props: { trigger: { @@ -55,11 +55,12 @@ export default { <p class="trigger-variables-btn-container d-flex"> <span class="font-weight-bold">{{ __('Trigger variables:') }}</span> - <gl-deprecated-button + <gl-button v-if="hasValues" - class="btn-sm group js-reveal-variables trigger-variables-btn" + class="group js-reveal-variables trigger-variables-btn" + size="small" @click="toggleValues" - >{{ getToggleButtonText }}</gl-deprecated-button + >{{ getToggleButtonText }}</gl-button > </p> diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index 8d6e5aac566..ea9c214de32 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -1,3 +1,5 @@ +import { parseBoolean } from '../../lib/utils/common_utils'; + /** * Adds the line number property * @param Object line @@ -17,7 +19,7 @@ export const parseLine = (line = {}, lineNumber) => ({ * @param Number lineNumber */ export const parseHeaderLine = (line = {}, lineNumber) => ({ - isClosed: false, + isClosed: parseBoolean(line.section_options?.collapsed), isHeader: true, line: parseLine(line, lineNumber), lines: [], diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js index 4922166acd0..469f7ce94b0 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/label_manager.js @@ -6,6 +6,7 @@ import Sortable from 'sortablejs'; import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; import { __ } from './locale'; +import { hide, dispose } from '~/tooltips'; export default class LabelManager { constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) { @@ -40,14 +41,14 @@ export default class LabelManager { const $label = $(`#${$btn.data('domId')}`); const action = $btn.parents('.js-prioritized-labels').length ? 'remove' : 'add'; const $tooltip = $(`#${$btn.find('.has-tooltip:visible').attr('aria-describedby')}`); - $tooltip.tooltip('dispose'); + dispose($tooltip); _this.toggleLabelPriority($label, action); _this.toggleEmptyState($label, $btn, action); } onButtonActionClick(e) { e.stopPropagation(); - $(e.currentTarget).tooltip('hide'); + hide(e.currentTarget); } toggleEmptyState() { diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 8e172b4827c..8bbd4300c96 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import { difference, isEqual, escape, sortBy, template, union } from 'lodash'; -import { sprintf, s__, __ } from './locale'; +import { sprintf, __ } from './locale'; import axios from './lib/utils/axios_utils'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import CreateLabelDropdown from './create_label'; @@ -43,7 +43,6 @@ export default class LabelsSelect { const $block = $selectbox.closest('.block'); const $form = $dropdown.closest('form, .js-issuable-update'); const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); - const $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); const $value = $block.find('.value'); const $dropdownMenu = $dropdown.parent().find('.dropdown-menu'); // eslint-disable-next-line no-jquery/no-fade @@ -57,7 +56,6 @@ export default class LabelsSelect { .get(); const scopedLabels = $dropdown.data('scopedLabels'); const { handleClick } = options; - $sidebarLabelTooltip.tooltip(); if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) { new CreateLabelDropdown( @@ -91,7 +89,6 @@ export default class LabelsSelect { axios .put(issueUpdateURL, data) .then(({ data }) => { - let labelTooltipTitle; let template; // eslint-disable-next-line no-jquery/no-fade $loading.fadeOut(); @@ -151,23 +148,6 @@ export default class LabelsSelect { $value.removeAttr('style').html(template); $sidebarCollapsedValue.text(labelCount); - if (data.labels.length) { - let labelTitles = data.labels.map(label => label.title); - - if (labelTitles.length > 5) { - labelTitles = labelTitles.slice(0, 5); - labelTitles.push( - sprintf(s__('Labels|and %{count} more'), { count: data.labels.length - 5 }), - ); - } - - labelTooltipTitle = labelTitles.join(', '); - } else { - labelTooltipTitle = __('Labels'); - } - - $sidebarLabelTooltip.attr('title', labelTooltipTitle).tooltip('_fixTitle'); - $('.has-tooltip', $value).tooltip({ container: 'body', }); diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index d7f5e6f8a5e..4d2955a8d3d 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -16,6 +16,15 @@ function initDeferred() { const whatsNewTriggerEl = document.querySelector('.js-whats-new-trigger'); if (whatsNewTriggerEl) { + const storageKey = whatsNewTriggerEl.getAttribute('data-storage-key'); + + $('.header-help').on('show.bs.dropdown', () => { + const displayNotification = JSON.parse(localStorage.getItem(storageKey)); + if (displayNotification === false) { + $('.js-whats-new-notification-count').remove(); + } + }); + whatsNewTriggerEl.addEventListener('click', () => { import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new') .then(({ default: initWhatsNew }) => { diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js new file mode 100644 index 00000000000..d9ea57fbbce --- /dev/null +++ b/app/assets/javascripts/lib/dompurify.js @@ -0,0 +1,53 @@ +import { sanitize as dompurifySanitize, addHook } from 'dompurify'; +import { getBaseURL, relativePathToAbsolute } from '~/lib/utils/url_utility'; + +// Safely allow SVG <use> tags + +const defaultConfig = { + ADD_TAGS: ['use'], +}; + +// Only icons urls from `gon` are allowed +const getAllowedIconUrls = (gon = window.gon) => + [gon.sprite_file_icons, gon.sprite_icons].filter(Boolean); + +const isUrlAllowed = url => getAllowedIconUrls().some(allowedUrl => url.startsWith(allowedUrl)); + +const isHrefSafe = url => + isUrlAllowed(url) || isUrlAllowed(relativePathToAbsolute(url, getBaseURL())); + +const removeUnsafeHref = (node, attr) => { + if (!node.hasAttribute(attr)) { + return; + } + + if (!isHrefSafe(node.getAttribute(attr))) { + node.removeAttribute(attr); + } +}; + +/** + * Sanitize icons' <use> tag attributes, to safely include + * svgs such as in: + * + * <svg viewBox="0 0 100 100"> + * <use href="/assets/icons-xxx.svg#icon_name"></use> + * </svg> + * + * @param {Object} node - Node to sanitize + */ +const sanitizeSvgIcon = node => { + removeUnsafeHref(node, 'href'); + + // Note: `xlink:href` is deprecated, but still in use + // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href + removeUnsafeHref(node, 'xlink:href'); +}; + +addHook('afterSanitizeAttributes', node => { + if (node.tagName.toLowerCase() === 'use') { + sanitizeSvgIcon(node); + } +}); + +export const sanitize = (val, config = defaultConfig) => dompurifySanitize(val, config); diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index d2907f401c0..0e07f7d8e44 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -31,6 +31,7 @@ export default (resolvers = {}, config = {}) => { // We set to `same-origin` which is default value in modern browsers. // See https://github.com/whatwg/fetch/pull/585 for more information. credentials: 'same-origin', + batchMax: config.batchMax || 10, }; const uploadsLink = ApolloLink.split( diff --git a/app/assets/javascripts/lib/utils/axios_startup_calls.js b/app/assets/javascripts/lib/utils/axios_startup_calls.js index 7e2665b910c..7bb1da5aed5 100644 --- a/app/assets/javascripts/lib/utils/axios_startup_calls.js +++ b/app/assets/javascripts/lib/utils/axios_startup_calls.js @@ -7,7 +7,7 @@ const removeGitLabUrl = url => url.replace(gon.gitlab_url, ''); const getFullUrl = req => { const url = removeGitLabUrl(req.url); - return mergeUrlParams(req.params || {}, url); + return mergeUrlParams(req.params || {}, url, { sort: true }); }; const handleStartupCall = async ({ fetchCall }, req) => { diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index bcf302cc262..fe1ac00fd1d 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -44,6 +44,7 @@ export const checkPageAndAction = (page, action) => { return pagePath === page && actionPath === action; }; +export const isInIncidentPage = () => checkPageAndAction('incidents', 'show'); export const isInIssuePage = () => checkPageAndAction('issues', 'show'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); export const isInEpicPage = () => checkPageAndAction('epics', 'show'); diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 993d51370ec..1a4ecc12f01 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,4 +1,5 @@ export const BYTES_IN_KIB = 1024; +export const BYTES_IN_KB = 1000; export const HIDDEN_CLASS = 'hidden'; export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; diff --git a/app/assets/javascripts/lib/utils/csrf.js b/app/assets/javascripts/lib/utils/csrf.js index ca9828c4682..3114a2a0dfb 100644 --- a/app/assets/javascripts/lib/utils/csrf.js +++ b/app/assets/javascripts/lib/utils/csrf.js @@ -1,5 +1,3 @@ -import $ from 'jquery'; - /* This module provides easy access to the CSRF token and caches it for re-use. It also exposes some values commonly used in relation @@ -20,7 +18,6 @@ If you need to compose a headers object, use the spread operator: see also http://guides.rubyonrails.org/security.html#cross-site-request-forgery-csrf and https://github.com/rails/jquery-rails/blob/v4.3.1/vendor/assets/javascripts/jquery_ujs.js#L59-L62 */ - const csrf = { init() { const tokenEl = document.querySelector('meta[name=csrf-token]'); @@ -52,9 +49,4 @@ const csrf = { csrf.init(); -// use our cached token for any $.rails-generated AJAX requests -if ($.rails) { - $.rails.csrfToken = () => csrf.token; -} - export default csrf; diff --git a/app/assets/javascripts/lib/utils/css_utils.js b/app/assets/javascripts/lib/utils/css_utils.js new file mode 100644 index 00000000000..90213221443 --- /dev/null +++ b/app/assets/javascripts/lib/utils/css_utils.js @@ -0,0 +1,19 @@ +export function loadCSSFile(path) { + return new Promise(resolve => { + if (document.querySelector(`link[href="${path}"]`)) { + resolve(); + } else { + const linkElement = document.createElement('link'); + linkElement.type = 'text/css'; + linkElement.rel = 'stylesheet'; + // eslint-disable-next-line @gitlab/require-i18n-strings + linkElement.media = 'screen,print'; + linkElement.onload = () => { + resolve(); + }; + linkElement.href = path; + + document.head.appendChild(linkElement); + } + }); +} diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index b193a8b2c9a..6e78dc87c02 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -86,6 +86,21 @@ export const getDayName = date => ][date.getDay()]; /** + * Returns the i18n month name from a given date + * @example + * formatDateAsMonth(new Date('2020-06-28')) -> 'Jun' + * @param {String} datetime where month is extracted from + * @param {Object} options + * @param {Boolean} options.abbreviated whether to use the abbreviated month string, or not + * @return {String} the i18n month name + */ +export function formatDateAsMonth(datetime, options = {}) { + const { abbreviated = true } = options; + const month = new Date(datetime).getMonth(); + return getMonthNames(abbreviated)[month]; +} + +/** * @example * dateFormat('2017-12-05','mmm d, yyyy h:MMtt Z' ) -> "Dec 5, 2017 12:00am GMT+0000" * @param {date} datetime @@ -730,6 +745,21 @@ export const differenceInSeconds = (startDate, endDate) => { }; /** + * A utility function which computes the difference in months + * between 2 dates. + * + * @param {Date} startDate the start date + * @param {Date} endDate the end date + * + * @return {Int} the difference in months + */ +export const differenceInMonths = (startDate, endDate) => { + const yearDiff = endDate.getYear() - startDate.getYear(); + const monthDiff = endDate.getMonth() - startDate.getMonth(); + return monthDiff + 12 * yearDiff; +}; + +/** * A utility function which computes the difference in milliseconds * between 2 dates. * @@ -743,3 +773,22 @@ export const differenceInMilliseconds = (startDate, endDate = Date.now()) => { const endDateInMS = endDate instanceof Date ? endDate.getTime() : endDate; return endDateInMS - startDateInMS; }; + +/** + * A utility which returns a new date at the first day of the month for any given date. + * + * @param {Date} date + * + * @return {Date} the date at the first day of the month + */ +export const dateAtFirstDayOfMonth = date => new Date(newDate(date).setDate(1)); + +/** + * A utility function which checks if two dates match. + * + * @param {Date|Int} date1 Can be either a date object or a unix timestamp. + * @param {Date|Int} date2 Can be either a date object or a unix timestamp. + * + * @return {Boolean} true if the dates match + */ +export const datesMatch = (date1, date2) => differenceInMilliseconds(date1, date2) === 0; diff --git a/app/assets/javascripts/lib/utils/experimentation.js b/app/assets/javascripts/lib/utils/experimentation.js new file mode 100644 index 00000000000..555e76055e0 --- /dev/null +++ b/app/assets/javascripts/lib/utils/experimentation.js @@ -0,0 +1,3 @@ +export function isExperimentEnabled(experimentKey) { + return Boolean(window.gon?.experiments?.[experimentKey]); +} diff --git a/app/assets/javascripts/lib/utils/highlight.js b/app/assets/javascripts/lib/utils/highlight.js index 32553af9af3..8fa8af670b3 100644 --- a/app/assets/javascripts/lib/utils/highlight.js +++ b/app/assets/javascripts/lib/utils/highlight.js @@ -1,5 +1,5 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import { sanitize } from 'dompurify'; +import { sanitize } from '~/lib/dompurify'; /** * Wraps substring matches with HTML `<span>` elements. diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index bc87232f40b..2424d6cbf3b 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -1,4 +1,4 @@ -import { BYTES_IN_KIB } from './constants'; +import { BYTES_IN_KIB, BYTES_IN_KB } from './constants'; import { sprintf, __ } from '~/locale'; /** @@ -35,6 +35,18 @@ export function formatRelevantDigits(number) { } /** + * Utility function that calculates KB of the given bytes. + * Note: This method calculates KiloBytes as opposed to + * Kibibytes. For Kibibytes, bytesToKiB should be used. + * + * @param {Number} number bytes + * @return {Number} KiB + */ +export function bytesToKB(number) { + return number / BYTES_IN_KB; +} + +/** * Utility function that calculates KiB of the given bytes. * * @param {Number} number bytes diff --git a/app/assets/javascripts/lib/utils/rails_ujs.js b/app/assets/javascripts/lib/utils/rails_ujs.js new file mode 100644 index 00000000000..8b40cc7bd11 --- /dev/null +++ b/app/assets/javascripts/lib/utils/rails_ujs.js @@ -0,0 +1,20 @@ +import Rails from '@rails/ujs'; + +export const initRails = () => { + // eslint-disable-next-line no-underscore-dangle + if (!window._rails_loaded) { + Rails.start(); + + // Count XHR requests for tests. See spec/support/helpers/wait_for_requests.rb + window.pendingRailsUJSRequests = 0; + document.body.addEventListener('ajax:complete', () => { + window.pendingRailsUJSRequests -= 1; + }); + + document.body.addEventListener('ajax:beforeSend', () => { + window.pendingRailsUJSRequests += 1; + }); + } +}; + +export { Rails }; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index f4c6e4e3584..dfb86787788 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -42,9 +42,7 @@ function convertMonacoSelectionToAceFormat(sel) { } function getEditorSelectionRange(editor) { - return window.gon.features?.monacoBlobs - ? convertMonacoSelectionToAceFormat(editor.getSelection()) - : editor.getSelectionRange(); + return convertMonacoSelectionToAceFormat(editor.getSelection()); } function editorBlockTagText(text, blockTag, selected, editor) { @@ -56,9 +54,6 @@ function editorBlockTagText(text, blockTag, selected, editor) { if (shouldRemoveBlock) { if (blockTag !== null) { - // ace is globally defined - // eslint-disable-next-line no-undef - const { Range } = ace.require('ace/range'); const lastLine = lines[selectionRange.end.row + 1]; const rangeWithBlockTags = new Range( lines[selectionRange.start.row - 1], @@ -110,12 +105,7 @@ function moveCursor({ const endPosition = startPosition + select.length; return textArea.setSelectionRange(startPosition, endPosition); } else if (editor) { - if (window.gon.features?.monacoBlobs) { - editor.selectWithinSelection(select, tag); - } else { - editor.navigateLeft(tag.length - tag.indexOf(select)); - editor.getSelection().selectAWord(); - } + editor.selectWithinSelection(select, tag); return; } } @@ -139,11 +129,7 @@ function moveCursor({ } } else if (editor && editorSelectionStart.row === editorSelectionEnd.row) { if (positionBetweenTags) { - if (window.gon.features?.monacoBlobs) { - editor.moveCursor(tag.length * -1); - } else { - editor.navigateLeft(tag.length); - } + editor.moveCursor(tag.length * -1); } } } @@ -166,6 +152,7 @@ export function insertMarkdownText({ let editorSelectionEnd; let lastNewLine; let textToInsert; + selected = selected.toString(); if (editor) { const selectionRange = getEditorSelectionRange(editor); @@ -265,11 +252,7 @@ export function insertMarkdownText({ } if (editor) { - if (window.gon.features?.monacoBlobs) { - editor.replaceSelectedText(textToInsert, select); - } else { - editor.insert(textToInsert); - } + editor.replaceSelectedText(textToInsert, select); } else { insertText(textArea, textToInsert); } diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js index adf374db66c..9f979f7ea4b 100644 --- a/app/assets/javascripts/lib/utils/unit_format/index.js +++ b/app/assets/javascripts/lib/utils/unit_format/index.js @@ -61,8 +61,8 @@ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => { * @function * @param {Number} value - Number to format * @param {Number} fractionDigits - precision decimals - * @param {Number} maxLength - Max lenght of formatted number - * if lenght is exceeded, exponential format is used. + * @param {Number} maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. */ return numberFormatter(); } @@ -73,8 +73,8 @@ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => { * @function * @param {Number} value - Number to format, `1` is rendered as `100%` * @param {Number} fractionDigits - number of precision decimals - * @param {Number} maxLength - Max lenght of formatted number - * if lenght is exceeded, exponential format is used. + * @param {Number} maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. */ return numberFormatter('percent'); } @@ -85,8 +85,8 @@ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => { * @function * @param {Number} value - Number to format, `100` is rendered as `100%` * @param {Number} fractionDigits - number of precision decimals - * @param {Number} maxLength - Max lenght of formatted number - * if lenght is exceeded, exponential format is used. + * @param {Number} maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. */ return numberFormatter('percent', 1 / 100); } @@ -100,8 +100,8 @@ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => { * @function * @param {Number} value - Number to format, `1` is rendered as `1s` * @param {Number} fractionDigits - number of precision decimals - * @param {Number} maxLength - Max lenght of formatted number - * if lenght is exceeded, exponential format is used. + * @param {Number} maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. */ return suffixFormatter(s__('Units|s')); } @@ -112,8 +112,8 @@ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => { * @function * @param {Number} value - Number to format, `1` is formatted as `1ms` * @param {Number} fractionDigits - number of precision decimals - * @param {Number} maxLength - Max lenght of formatted number - * if lenght is exceeded, exponential format is used. + * @param {Number} maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. */ return suffixFormatter(s__('Units|ms')); } diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index e9c3fe0a406..a9f6901de32 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -16,7 +16,7 @@ function decodeUrlParameter(val) { return decodeURIComponent(val.replace(/\+/g, '%20')); } -function cleanLeadingSeparator(path) { +export function cleanLeadingSeparator(path) { return path.replace(PATH_SEPARATOR_LEADING_REGEX, ''); } @@ -73,6 +73,7 @@ export function getParameterValues(sParam, url = window.location) { * @param {String} url * @param {Object} options * @param {Boolean} options.spreadArrays - split array values into separate key/value-pairs + * @param {Boolean} options.sort - alphabetically sort params in the returned url (in asc order, i.e., a-z) */ export function mergeUrlParams(params, url, options = {}) { const { spreadArrays = false, sort = false } = options; @@ -255,6 +256,15 @@ export function getBaseURL() { } /** + * Takes a URL and returns content from the start until the final '/' + * + * @param {String} url - full url, including protocol and host + */ +export function stripFinalUrlSegment(url) { + return new URL('.', url).href; +} + +/** * Returns true if url is an absolute URL * * @param {String} url @@ -282,6 +292,15 @@ export function isBase64DataUrl(url) { } /** + * Returns true if url is a blob: type url + * + * @param {String} url + */ +export function isBlobUrl(url) { + return /^blob:/.test(url); +} + +/** * Returns true if url is an absolute or root-relative URL * * @param {String} url @@ -434,3 +453,24 @@ export function getHTTPProtocol(url) { const protocol = url.split(':'); return protocol.length > 1 ? protocol[0] : undefined; } + +/** + * Strips the filename from the given path by removing every non-slash character from the end of the + * passed parameter. + * @param {string} path + */ +export function stripPathTail(path = '') { + return path.replace(/[^/]+$/, ''); +} + +export function getURLOrigin(url) { + if (!url) { + return window.location.origin; + } + + try { + return new URL(url).origin; + } catch (e) { + return null; + } +} diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index 97b96cb5839..f7c0bd5ae13 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -3,12 +3,11 @@ import { throttle } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import { GlSprintf, - GlIcon, GlAlert, - GlDeprecatedDropdown, - GlDeprecatedDropdownHeader, - GlDeprecatedDropdownItem, - GlDeprecatedDropdownDivider, + GlDropdown, + GlDropdownSectionHeader, + GlDropdownItem, + GlDropdownDivider, GlInfiniteScroll, } from '@gitlab/ui'; @@ -23,12 +22,11 @@ import { formatDate } from '../utils'; export default { components: { GlSprintf, - GlIcon, GlAlert, - GlDeprecatedDropdown, - GlDeprecatedDropdownHeader, - GlDeprecatedDropdownItem, - GlDeprecatedDropdownDivider, + GlDropdown, + GlDropdownSectionHeader, + GlDropdownItem, + GlDropdownDivider, GlInfiniteScroll, LogSimpleFilters, LogAdvancedFilters, @@ -174,46 +172,38 @@ export default { <div class="top-bar d-md-flex border bg-secondary-50 pt-2 pr-1 pb-0 pl-2"> <div class="flex-grow-0"> - <gl-deprecated-dropdown + <gl-dropdown id="environments-dropdown" :text="environments.current || managedApps.current" :disabled="environments.isLoading" - class="mb-2 gl-h-32 pr-2 d-flex d-md-block js-environments-dropdown" + class="gl-mr-3 gl-mb-3 gl-display-flex gl-display-md-block js-environments-dropdown" > - <gl-deprecated-dropdown-header class="gl-text-center"> + <gl-dropdown-section-header> {{ s__('Environments|Environments') }} - </gl-deprecated-dropdown-header> - <gl-deprecated-dropdown-item + </gl-dropdown-section-header> + <gl-dropdown-item v-for="env in environments.options" :key="env.id" + :is-check-item="true" + :is-checked="isCurrentEnvironment(env.name)" @click="showEnvironment(env.name)" > - <div class="d-flex"> - <gl-icon - :class="{ invisible: !isCurrentEnvironment(env.name) }" - name="status_success_borderless" - /> - <div class="gl-flex-grow-1">{{ env.name }}</div> - </div> - </gl-deprecated-dropdown-item> - <gl-deprecated-dropdown-divider /> - <gl-deprecated-dropdown-header class="gl-text-center"> + {{ env.name }} + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-section-header> {{ s__('Environments|Managed apps') }} - </gl-deprecated-dropdown-header> - <gl-deprecated-dropdown-item + </gl-dropdown-section-header> + <gl-dropdown-item v-for="app in managedApps.options" :key="app.id" + :is-check-item="true" + :is-checked="isCurrentManagedApp(app.name)" @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-deprecated-dropdown-item> - </gl-deprecated-dropdown> + {{ app.name }} + </gl-dropdown-item> + </gl-dropdown> </div> <log-advanced-filters diff --git a/app/assets/javascripts/logs/components/log_simple_filters.vue b/app/assets/javascripts/logs/components/log_simple_filters.vue index 2e1270b5428..ba30d4628c9 100644 --- a/app/assets/javascripts/logs/components/log_simple_filters.vue +++ b/app/assets/javascripts/logs/components/log_simple_filters.vue @@ -1,19 +1,13 @@ <script> import { mapActions, mapState } from 'vuex'; -import { - GlIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownHeader, - GlDeprecatedDropdownItem, -} from '@gitlab/ui'; +import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem } from '@gitlab/ui'; import { s__ } from '~/locale'; export default { components: { - GlIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownHeader, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownSectionHeader, + GlDropdownItem, }, props: { disabled: { @@ -44,35 +38,31 @@ export default { </script> <template> <div> - <gl-deprecated-dropdown + <gl-dropdown ref="podsDropdown" :text="podDropdownText" :disabled="disabled" - class="mb-2 gl-h-32 pr-2 d-flex d-md-block flex-grow-0 qa-pods-dropdown" + class="gl-mr-3 gl-mb-3 gl-display-flex gl-display-md-block qa-pods-dropdown" > - <gl-deprecated-dropdown-header class="text-center"> + <gl-dropdown-section-header> {{ s__('Environments|Select pod') }} - </gl-deprecated-dropdown-header> + </gl-dropdown-section-header> - <gl-deprecated-dropdown-item v-if="!pods.options.length" disabled> + <gl-dropdown-item v-if="!pods.options.length" disabled> <span ref="noPodsMsg" class="text-muted"> {{ s__('Environments|No pods to display') }} </span> - </gl-deprecated-dropdown-item> - <gl-deprecated-dropdown-item + </gl-dropdown-item> + <gl-dropdown-item v-for="podName in pods.options" :key="podName" + :is-check-item="true" + :is-checked="isCurrentPod(podName)" class="text-nowrap" @click="showPodLogs(podName)" > - <div class="d-flex"> - <gl-icon - :class="{ invisible: !isCurrentPod(podName) }" - name="status_success_borderless" - /> - <div class="flex-grow-1">{{ podName }}</div> - </div> - </gl-deprecated-dropdown-item> - </gl-deprecated-dropdown> + {{ podName }} + </gl-dropdown-item> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 9fcf881a1ac..d60f949c49d 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -11,6 +11,7 @@ import './behaviors'; // lib/utils import applyGitLabUIConfig from '@gitlab/ui/dist/config'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import { initRails } from '~/lib/utils/rails_ujs'; import { handleLocationHash, addSelectOnFocusBehaviour, @@ -38,6 +39,8 @@ import initPersistentUserCallouts from './persistent_user_callouts'; import { initUserTracking, initDefaultTrackers } from './tracking'; import { __ } from './locale'; +import * as tooltips from '~/tooltips'; + import 'ee_else_ce/main_ee'; applyGitLabUIConfig(); @@ -76,7 +79,7 @@ document.addEventListener('beforeunload', () => { // Unbind scroll events $(document).off('scroll'); // Close any open tooltips - $('.has-tooltip, [data-toggle="tooltip"]').tooltip('dispose'); + tooltips.dispose(document.querySelectorAll('.has-tooltip, [data-toggle="tooltip"]')); // Close any open popover $('[data-toggle="popover"]').popover('dispose'); }); @@ -96,6 +99,8 @@ gl.lazyLoader = new LazyLoader({ observerNode: '#content-body', }); +initRails(); + // Put all initialisations here that can also wait after everything is rendered and ready function deferredInitialisation() { const $body = $('body'); @@ -130,8 +135,10 @@ function deferredInitialisation() { addSelectOnFocusBehaviour('.js-select-on-focus'); $('.remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() { + tooltips.dispose(this); + + // eslint-disable-next-line no-jquery/no-fade $(this) - .tooltip('dispose') .closest('li') .fadeOut(); }); @@ -151,7 +158,7 @@ function deferredInitialisation() { const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0; // Initialize tooltips - $body.tooltip({ + tooltips.initTooltips({ selector: '.has-tooltip, [data-toggle="tooltip"]', trigger: 'hover', boundary: 'viewport', diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index c3fbb5d6acf..6dd4018f87a 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -1,6 +1,8 @@ import $ from 'jquery'; +import { Rails } from '~/lib/utils/rails_ujs'; import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { __, sprintf } from '~/locale'; export default class Members { constructor() { @@ -54,15 +56,43 @@ export default class Members { formSubmit(e, $el = null) { const $this = e ? $(e.currentTarget) : $el; const { $toggle, $dateInput } = this.getMemberListItems($this); + const formEl = $this.closest('form').get(0); - $this.closest('form').trigger('submit.rails'); + Rails.fire(formEl, 'submit'); $toggle.disable(); $dateInput.disable(); } formSuccess(e) { - const { $toggle, $dateInput } = this.getMemberListItems($(e.currentTarget).closest('.member')); + const { $toggle, $dateInput, $expiresIn, $expiresInText } = this.getMemberListItems( + $(e.currentTarget).closest('.js-member'), + ); + + const [data] = e.detail; + const expiresIn = data?.expires_in; + + if (expiresIn) { + $expiresIn.removeClass('gl-display-none'); + + $expiresInText.text(sprintf(__('Expires in %{expires_at}'), { expires_at: expiresIn })); + + const { expires_soon: expiresSoon, expires_at_formatted: expiresAtFormatted } = data; + + if (expiresSoon) { + $expiresInText.addClass('text-warning'); + } else { + $expiresInText.removeClass('text-warning'); + } + + // Update tooltip + if (expiresAtFormatted) { + $expiresInText.attr('title', expiresAtFormatted); + $expiresInText.attr('data-original-title', expiresAtFormatted); + } + } else { + $expiresIn.addClass('gl-display-none'); + } $toggle.enable(); $dateInput.enable(); @@ -70,10 +100,12 @@ export default class Members { // eslint-disable-next-line class-methods-use-this getMemberListItems($el) { - const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('elId')}`); + const $memberListItem = $el.is('.js-member') ? $el : $(`#${$el.data('elId')}`); return { $memberListItem, + $expiresIn: $memberListItem.find('.js-expires-in'), + $expiresInText: $memberListItem.find('.js-expires-in-text'), $toggle: $memberListItem.find('.dropdown-menu-toggle'), $dateInput: $memberListItem.find('.js-access-expiration-date'), }; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 79a4c3700ef..fe4e2cee69f 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -3,11 +3,12 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; import { __ } from '~/locale'; +import eventHub from '~/vue_merge_request_widget/event_hub'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import TaskList from './task_list'; import MergeRequestTabs from './merge_request_tabs'; -import IssuablesHelper from './helpers/issuables_helper'; import { addDelimiter } from './lib/utils/text_utility'; +import { getParameterValues, setUrlParams } from './lib/utils/url_utility'; function MergeRequest(opts) { // Initialize MergeRequest behavior @@ -23,7 +24,6 @@ function MergeRequest(opts) { this.initTabs(); this.initMRBtnListeners(); this.initCommitMessageListeners(); - this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport(); if ($('.description.js-task-list-container').length) { this.taskList = new TaskList({ @@ -66,13 +66,38 @@ MergeRequest.prototype.showAllCommits = function() { MergeRequest.prototype.initMRBtnListeners = function() { const _this = this; + const draftToggles = document.querySelectorAll('.js-draft-toggle-button'); - $('.report-abuse-link').on('click', e => { - // this is needed because of the implementation of - // the dropdown toggle and Report Abuse needing to be - // linked to another page. - e.stopPropagation(); - }); + if (draftToggles.length) { + draftToggles.forEach(draftToggle => { + draftToggle.addEventListener('click', e => { + e.preventDefault(); + e.stopImmediatePropagation(); + + const url = draftToggle.href; + const wipEvent = getParameterValues('merge_request[wip_event]', url)[0]; + const mobileDropdown = draftToggle.closest('.dropdown.show'); + + if (mobileDropdown) { + $(mobileDropdown.firstElementChild).dropdown('toggle'); + } + + draftToggle.setAttribute('disabled', 'disabled'); + + axios + .put(draftToggle.href, null, { params: { format: 'json' } }) + .then(({ data }) => { + draftToggle.removeAttribute('disabled'); + eventHub.$emit('MRWidgetUpdateRequested'); + MergeRequest.toggleDraftStatus(data.title, wipEvent === 'unwip'); + }) + .catch(() => { + draftToggle.removeAttribute('disabled'); + createFlash(__('Something went wrong. Please try again.')); + }); + }); + }); + } return $('.btn-close, .btn-reopen').on('click', function(e) { const $this = $(this); @@ -89,8 +114,6 @@ MergeRequest.prototype.initMRBtnListeners = function() { return; } - if (this.closeReopenReportToggle) this.closeReopenReportToggle.setDisable(); - if (shouldSubmit) { if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) { e.preventDefault(); @@ -151,14 +174,35 @@ MergeRequest.hideCloseButton = function() { const closeDropdownItem = el.querySelector('li.close-item'); if (closeDropdownItem) { closeDropdownItem.classList.add('hidden'); - // Selects the next dropdown item - el.querySelector('li.report-item').click(); - } else { - // No dropdown just hide the Close button - el.querySelector('.btn-close').classList.add('hidden'); } // Dropdown for mobile screen el.querySelector('li.js-close-item').classList.add('hidden'); }; +MergeRequest.toggleDraftStatus = function(title, isReady) { + if (isReady) { + createFlash(__('The merge request can now be merged.'), 'notice'); + } + const titleEl = document.querySelector('.merge-request .detail-page-description .title'); + + if (titleEl) { + titleEl.textContent = title; + } + + const draftToggles = document.querySelectorAll('.js-draft-toggle-button'); + + if (draftToggles.length) { + draftToggles.forEach(el => { + const draftToggle = el; + const url = setUrlParams( + { 'merge_request[wip_event]': isReady ? 'wip' : 'unwip' }, + draftToggle.href, + ); + + draftToggle.setAttribute('href', url); + draftToggle.textContent = isReady ? __('Mark as draft') : __('Mark as ready'); + }); + } +}; + export default MergeRequest; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index b7cf39db00c..bdcdabe8f78 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -396,10 +396,6 @@ export default class MergeRequestTabs { initChangesDropdown(this.stickyTop); - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - gl.diffNotesCompileComponents(); - } - localTimeAgo($('.js-timeago', 'div#diffs')); syntaxHighlight($('#diffs .js-syntax-highlight')); @@ -482,13 +478,14 @@ export default class MergeRequestTabs { } shrinkView() { - const $gutterIcon = $('.js-sidebar-toggle i:visible'); + const $gutterBtn = $('.js-sidebar-toggle:visible'); + const $expandSvg = $gutterBtn.find('.js-sidebar-expand'); // Wait until listeners are set setTimeout(() => { // Only when sidebar is expanded - if ($gutterIcon.is('.fa-angle-double-right')) { - $gutterIcon.closest('a').trigger('click', [true]); + if ($expandSvg.length && $expandSvg.hasClass('hidden')) { + $gutterBtn.trigger('click', [true]); } }, 0); } @@ -498,13 +495,14 @@ export default class MergeRequestTabs { if (parseBoolean(Cookies.get('collapsed_gutter'))) { return; } - const $gutterIcon = $('.js-sidebar-toggle i:visible'); + const $gutterBtn = $('.js-sidebar-toggle'); + const $collapseSvg = $gutterBtn.find('.js-sidebar-collapse'); // Wait until listeners are set setTimeout(() => { // Only when sidebar is collapsed - if ($gutterIcon.is('.fa-angle-double-left')) { - $gutterIcon.closest('a').trigger('click', [true]); + if ($collapseSvg.length && !$collapseSvg.hasClass('hidden')) { + $gutterBtn.trigger('click', [true]); } }, 0); } diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js index 20d9fb82554..52e9b67c77d 100644 --- a/app/assets/javascripts/milestone.js +++ b/app/assets/javascripts/milestone.js @@ -7,11 +7,6 @@ import { __ } from './locale'; export default class Milestone { constructor() { this.bindTabsSwitching(); - - // Load merge request tab if it is active - // merge request tab is active based on different conditions in the backend - this.loadTab($('.js-milestone-tabs .active a')); - this.loadInitialTab(); } @@ -23,12 +18,14 @@ export default class Milestone { this.loadTab($target); }); } - // eslint-disable-next-line class-methods-use-this + loadInitialTab() { - const $target = $(`.js-milestone-tabs a[href="${window.location.hash}"]`); + const $target = $(`.js-milestone-tabs a:not(.active)[href="${window.location.hash}"]`); if ($target.length) { $target.tab('show'); + } else { + this.loadTab($('.js-milestone-tabs a.active')); } } // eslint-disable-next-line class-methods-use-this diff --git a/app/assets/javascripts/milestones/project_milestone_combobox.vue b/app/assets/javascripts/milestones/project_milestone_combobox.vue index 5ee917573ce..0fa5585e858 100644 --- a/app/assets/javascripts/milestones/project_milestone_combobox.vue +++ b/app/assets/javascripts/milestones/project_milestone_combobox.vue @@ -205,7 +205,6 @@ export default { <gl-search-box-by-type ref="searchBox" v-model.trim="searchQuery" - class="gl-m-3" :placeholder="this.$options.translations.searchMilestones" @input="onSearchBoxInput" @keydown.enter.prevent="onSearchBoxEnter" diff --git a/app/assets/javascripts/milestones/stores/actions.js b/app/assets/javascripts/milestones/stores/actions.js new file mode 100644 index 00000000000..3859771aeba --- /dev/null +++ b/app/assets/javascripts/milestones/stores/actions.js @@ -0,0 +1,58 @@ +import Api from '~/api'; +import * as types from './mutation_types'; + +export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId); + +export const setSelectedMilestones = ({ commit }, selectedMilestones) => + commit(types.SET_SELECTED_MILESTONES, selectedMilestones); + +export const toggleMilestones = ({ commit, state }, selectedMilestone) => { + const removeMilestone = state.selectedMilestones.includes(selectedMilestone); + + if (removeMilestone) { + commit(types.REMOVE_SELECTED_MILESTONE, selectedMilestone); + } else { + commit(types.ADD_SELECTED_MILESTONE, selectedMilestone); + } +}; + +export const search = ({ dispatch, commit }, query) => { + commit(types.SET_QUERY, query); + + dispatch('searchMilestones'); +}; + +export const fetchMilestones = ({ commit, state }) => { + commit(types.REQUEST_START); + + Api.projectMilestones(state.projectId) + .then(response => { + commit(types.RECEIVE_PROJECT_MILESTONES_SUCCESS, response); + }) + .catch(error => { + commit(types.RECEIVE_PROJECT_MILESTONES_ERROR, error); + }) + .finally(() => { + commit(types.REQUEST_FINISH); + }); +}; + +export const searchMilestones = ({ commit, state }) => { + commit(types.REQUEST_START); + + const options = { + search: state.query, + scope: 'milestones', + }; + + Api.projectSearch(state.projectId, options) + .then(response => { + commit(types.RECEIVE_PROJECT_MILESTONES_SUCCESS, response); + }) + .catch(error => { + commit(types.RECEIVE_PROJECT_MILESTONES_ERROR, error); + }) + .finally(() => { + commit(types.REQUEST_FINISH); + }); +}; diff --git a/app/assets/javascripts/milestones/stores/getters.js b/app/assets/javascripts/milestones/stores/getters.js new file mode 100644 index 00000000000..d8a283403ec --- /dev/null +++ b/app/assets/javascripts/milestones/stores/getters.js @@ -0,0 +1,2 @@ +/** Returns `true` if there is at least one in-progress request */ +export const isLoading = ({ requestCount }) => requestCount > 0; diff --git a/app/assets/javascripts/registry/settings/store/index.js b/app/assets/javascripts/milestones/stores/index.js index c2500454d8e..2bebffc19ab 100644 --- a/app/assets/javascripts/registry/settings/store/index.js +++ b/app/assets/javascripts/milestones/stores/index.js @@ -1,18 +1,16 @@ import Vue from 'vue'; import Vuex from 'vuex'; import * as actions from './actions'; -import mutations from './mutations'; import * as getters from './getters'; -import state from './state'; +import mutations from './mutations'; +import createState from './state'; Vue.use(Vuex); -export const createStore = () => +export default () => new Vuex.Store({ - state, actions, - mutations, getters, + mutations, + state: createState(), }); - -export default createStore(); diff --git a/app/assets/javascripts/milestones/stores/mutation_types.js b/app/assets/javascripts/milestones/stores/mutation_types.js new file mode 100644 index 00000000000..370d386dba2 --- /dev/null +++ b/app/assets/javascripts/milestones/stores/mutation_types.js @@ -0,0 +1,13 @@ +export const SET_PROJECT_ID = 'SET_PROJECT_ID'; + +export const SET_SELECTED_MILESTONES = 'SET_SELECTED_MILESTONES'; +export const ADD_SELECTED_MILESTONE = 'ADD_SELECTED_MILESTONE'; +export const REMOVE_SELECTED_MILESTONE = 'REMOVE_SELECTED_MILESTONE'; + +export const SET_QUERY = 'SET_QUERY'; + +export const REQUEST_START = 'REQUEST_START'; +export const REQUEST_FINISH = 'REQUEST_FINISH'; + +export const RECEIVE_PROJECT_MILESTONES_SUCCESS = 'RECEIVE_PROJECT_MILESTONES_SUCCESS'; +export const RECEIVE_PROJECT_MILESTONES_ERROR = 'RECEIVE_PROJECT_MILESTONES_ERROR'; diff --git a/app/assets/javascripts/milestones/stores/mutations.js b/app/assets/javascripts/milestones/stores/mutations.js new file mode 100644 index 00000000000..7c75d09766c --- /dev/null +++ b/app/assets/javascripts/milestones/stores/mutations.js @@ -0,0 +1,44 @@ +import Vue from 'vue'; +import * as types from './mutation_types'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +export default { + [types.SET_PROJECT_ID](state, projectId) { + state.projectId = projectId; + }, + [types.SET_SELECTED_MILESTONES](state, selectedMilestones) { + Vue.set(state, 'selectedMilestones', selectedMilestones); + }, + [types.ADD_SELECTED_MILESTONE](state, selectedMilestone) { + state.selectedMilestones.push(selectedMilestone); + }, + [types.REMOVE_SELECTED_MILESTONE](state, selectedMilestone) { + const filteredMilestones = state.selectedMilestones.filter( + milestone => milestone !== selectedMilestone, + ); + Vue.set(state, 'selectedMilestones', filteredMilestones); + }, + [types.SET_QUERY](state, query) { + state.query = query; + }, + [types.REQUEST_START](state) { + state.requestCount += 1; + }, + [types.REQUEST_FINISH](state) { + state.requestCount -= 1; + }, + [types.RECEIVE_PROJECT_MILESTONES_SUCCESS](state, response) { + state.matches.projectMilestones = { + list: convertObjectPropsToCamelCase(response.data).map(({ title }) => ({ title })), + totalCount: parseInt(response.headers['x-total'], 10), + error: null, + }; + }, + [types.RECEIVE_PROJECT_MILESTONES_ERROR](state, error) { + state.matches.projectMilestones = { + list: [], + totalCount: 0, + error, + }; + }, +}; diff --git a/app/assets/javascripts/milestones/stores/state.js b/app/assets/javascripts/milestones/stores/state.js new file mode 100644 index 00000000000..0944539f367 --- /dev/null +++ b/app/assets/javascripts/milestones/stores/state.js @@ -0,0 +1,14 @@ +export default () => ({ + projectId: null, + groupId: null, + query: '', + matches: { + projectMilestones: { + list: [], + totalCount: 0, + error: null, + }, + }, + selectedMilestones: [], + requestCount: 0, +}); diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js index cc787613c52..818ca8aa847 100644 --- a/app/assets/javascripts/mirrors/mirror_repos.js +++ b/app/assets/javascripts/mirrors/mirror_repos.js @@ -4,6 +4,7 @@ import { __ } from '~/locale'; import { deprecatedCreateFlash as Flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import SSHMirror from './ssh_mirror'; +import { hide } from '~/tooltips'; export default class MirrorRepos { constructor(container) { @@ -115,7 +116,7 @@ export default class MirrorRepos { /* eslint-disable class-methods-use-this */ removeRow($target) { const row = $target.closest('tr'); - $('.js-delete-mirror', row).tooltip('hide'); + hide($('.js-delete-mirror', row)); row.remove(); } /* eslint-enable class-methods-use-this */ diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue index 132df9c9516..6f29b34141d 100644 --- a/app/assets/javascripts/monitoring/components/alert_widget_form.vue +++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue @@ -3,7 +3,7 @@ import { isEmpty, findKey } from 'lodash'; import Vue from 'vue'; import { GlLink, - GlDeprecatedButton, + GlButton, GlButtonGroup, GlFormGroup, GlFormInput, @@ -36,7 +36,7 @@ const SUBMIT_BUTTON_CLASS = { export default { components: { - GlDeprecatedButton, + GlButton, GlButtonGroup, GlFormGroup, GlFormInput, @@ -267,30 +267,27 @@ export default { </gl-dropdown> </gl-form-group> <gl-button-group class="mb-3" :label="s__('PrometheusAlerts|Operator')"> - <gl-deprecated-button + <gl-button :class="{ active: operator === operators.greaterThan }" :disabled="formDisabled" - type="button" @click="operator = operators.greaterThan" > {{ operators.greaterThan }} - </gl-deprecated-button> - <gl-deprecated-button + </gl-button> + <gl-button :class="{ active: operator === operators.equalTo }" :disabled="formDisabled" - type="button" @click="operator = operators.equalTo" > {{ operators.equalTo }} - </gl-deprecated-button> - <gl-deprecated-button + </gl-button> + <gl-button :class="{ active: operator === operators.lessThan }" :disabled="formDisabled" - type="button" @click="operator = operators.lessThan" > {{ operators.lessThan }} - </gl-deprecated-button> + </gl-button> </gl-button-group> <gl-form-group :label="s__('PrometheusAlerts|Threshold')" label-for="alerts-threshold"> <gl-form-input diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index e468728a954..0f6a9ce3814 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -192,7 +192,7 @@ export default { > <div class="d-flex flex-column overflow-hidden"> <gl-dropdown-section-header>{{ __('Environment') }}</gl-dropdown-section-header> - <gl-search-box-by-type class="gl-m-3" @input="debouncedEnvironmentsSearch" /> + <gl-search-box-by-type @input="debouncedEnvironmentsSearch" /> <gl-loading-icon v-if="environmentsLoading" :inline="true" /> <div v-else class="flex-fill overflow-auto"> diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue index 932efeaaf0e..1a349aa154a 100644 --- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -80,11 +80,7 @@ export default { > <div class="d-flex flex-column overflow-hidden"> <gl-dropdown-section-header>{{ __('Dashboard') }}</gl-dropdown-section-header> - <gl-search-box-by-type - ref="monitorDashboardsDropdownSearch" - v-model="searchTerm" - class="gl-m-3" - /> + <gl-search-box-by-type ref="monitorDashboardsDropdownSearch" v-model="searchTerm" /> <div class="flex-fill overflow-auto"> <gl-dropdown-item diff --git a/app/assets/javascripts/monitoring/components/group_empty_state.vue b/app/assets/javascripts/monitoring/components/group_empty_state.vue index 499823fae3f..0365fc66331 100644 --- a/app/assets/javascripts/monitoring/components/group_empty_state.vue +++ b/app/assets/javascripts/monitoring/components/group_empty_state.vue @@ -1,6 +1,5 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlEmptyState } from '@gitlab/ui'; +import { GlEmptyState, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import { metricStates } from '../constants'; @@ -8,6 +7,9 @@ export default { components: { GlEmptyState, }, + directives: { + SafeHtml, + }, props: { documentationPath: { type: String, @@ -100,7 +102,7 @@ export default { :compact="true" > <template v-if="currentState.slottedDescription" #description> - <div v-html="currentState.slottedDescription"></div> + <div v-safe-html="currentState.slottedDescription"></div> </template> </gl-empty-state> </template> diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 16a685305dc..e7391a4c9d1 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/wrapper'; import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; diff --git a/app/assets/javascripts/namespaces/leave_by_url.js b/app/assets/javascripts/namespaces/leave_by_url.js index bf77617d516..7b15253d872 100644 --- a/app/assets/javascripts/namespaces/leave_by_url.js +++ b/app/assets/javascripts/namespaces/leave_by_url.js @@ -1,3 +1,4 @@ +import { initRails } from '~/lib/utils/rails_ujs'; import { deprecatedCreateFlash as Flash } from '~/flash'; import { __, sprintf } from '~/locale'; import { getParameterByName } from '~/lib/utils/common_utils'; @@ -11,6 +12,8 @@ export default function leaveByUrl(namespaceType) { const param = getParameterByName(PARAMETER_NAME); if (!param) return; + initRails(); + const leaveLink = document.querySelector(LEAVE_LINK_SELECTOR); if (leaveLink) { leaveLink.click(); diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index 3bbaa44ec42..c04f2a2d465 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -1,8 +1,8 @@ <script> /* eslint-disable vue/no-v-html */ import marked from 'marked'; -import { sanitize } from 'dompurify'; import katex from 'katex'; +import { sanitize } from '~/lib/dompurify'; import Prompt from './prompt.vue'; const renderer = new marked.Renderer(); diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue index 856c8f31796..4d527baf730 100644 --- a/app/assets/javascripts/notebook/cells/output/html.vue +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -1,6 +1,6 @@ <script> /* eslint-disable vue/no-v-html */ -import { sanitize } from 'dompurify'; +import { sanitize } from '~/lib/dompurify'; import Prompt from '../prompt.vue'; export default { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 340fbe4d887..37bb79defd1 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -479,11 +479,6 @@ export default class Notes { row = form; } - const lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; - const diffAvatarContainer = row - .prevAll('.line_holder') - .first() - .find(`.js-avatar-container.${lineType}_line`); // is this the first note of discussion? discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); if (!discussionContainer.length) { @@ -519,12 +514,6 @@ export default class Notes { Notes.animateAppendNote(noteEntity.html, discussionContainer); } - if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { - gl.diffNotesCompileComponents(); - - this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); - } - localTimeAgo($('.js-timeago'), false); Notes.checkMergeRequestStatus(); return this.updateNotesCount(1); @@ -538,19 +527,6 @@ export default class Notes { .get(0); } - renderDiscussionAvatar(diffAvatarContainer, noteEntity) { - let avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders'); - - if (!avatarHolder.length) { - avatarHolder = document.createElement('diff-note-avatars'); - avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id); - - diffAvatarContainer.append(avatarHolder); - - gl.diffNotesCompileComponents(); - } - } - /** * Called in response the main target form has been successfully submitted. * @@ -605,10 +581,6 @@ export default class Notes { form.find('#note_type').val(''); form.find('#note_project_id').remove(); form.find('#in_reply_to_discussion_id').remove(); - form - .find('.js-comment-resolve-button') - .closest('comment-and-resolve-btn') - .remove(); this.parentTimeline = form.parents('.timeline'); if (form.length) { @@ -714,10 +686,6 @@ export default class Notes { $note_li.replaceWith($noteEntityEl); this.setupNewNote($noteEntityEl); - - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - gl.diffNotesCompileComponents(); - } } checkContentToAllowEditing($el) { @@ -844,12 +812,6 @@ export default class Notes { const $notes = $note.closest('.discussion-notes'); const discussionId = $('.notes', $notes).data('discussionId'); - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - if (gl.diffNoteApps[noteElId]) { - gl.diffNoteApps[noteElId].$destroy(); - } - } - $note.remove(); // check if this is the last note for this line @@ -979,13 +941,6 @@ export default class Notes { form.removeClass('js-main-target-form').addClass('discussion-form js-discussion-note-form'); - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - const $commentBtn = form.find('comment-and-resolve-btn'); - $commentBtn.attr(':discussion-id', `'${discussionID}'`); - - gl.diffNotesCompileComponents(); - } - form.find('.js-note-text').focus(); form.find('.js-comment-resolve-button').attr('data-discussion-id', discussionID); } diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 54fcf41ca50..cfdadbceaf6 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -371,6 +371,7 @@ export default { :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" :add-spacing-classes="false" + :textarea-value="note" > <textarea id="note-body" @@ -380,7 +381,8 @@ export default { dir="auto" :disabled="isSubmitting" name="note[note]" - class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area qa-comment-input" + class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area" + data-qa-selector="comment_field" data-supports-quick-actions="true" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" @@ -425,7 +427,8 @@ export default { > <gl-button :disabled="isSubmitButtonDisabled" - class="js-comment-button js-comment-submit-button qa-comment-button" + class="js-comment-button js-comment-submit-button" + data-qa-selector="comment_button" type="submit" category="primary" variant="success" @@ -439,7 +442,8 @@ export default { name="button" category="primary" variant="success" - class="note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" + class="note-type-toggle js-note-new-discussion dropdown-toggle" + data-qa-selector="note_dropdown" data-display="static" data-toggle="dropdown" icon="chevron-down" @@ -468,7 +472,10 @@ export default { </li> <li class="divider droplab-item-ignore"></li> <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> - <button class="qa-discussion-option" @click.prevent="setNoteType('discussion')"> + <button + data-qa-selector="discussion_menu_item" + @click.prevent="setNoteType('discussion')" + > <i aria-hidden="true" class="fa fa-check icon"></i> <div class="description"> <strong>{{ __('Start thread') }}</strong> diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index 8e6c01ba63f..ee39a529345 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -1,7 +1,7 @@ <script> -/* eslint-disable vue/no-v-html */ import { mapActions } from 'vuex'; import { escape } from 'lodash'; +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { s__, __, sprintf } from '~/locale'; import { truncateSha } from '~/lib/utils/text_utility'; @@ -17,6 +17,9 @@ export default { noteEditedText, noteHeader, }, + directives: { + SafeHtml, + }, props: { discussion: { type: Object, @@ -113,7 +116,7 @@ export default { :expanded="discussion.expanded" @toggleHandler="toggleDiscussionHandler" > - <span v-html="headerText"></span> + <span v-safe-html="headerText"></span> </note-header> <note-edited-text v-if="discussion.resolved" diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index c01cd8f8037..a4271852563 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -76,7 +76,7 @@ export default { :discussion-path="discussion.discussion_path" :diff-file="discussion.diff_file" :can-current-user-fork="false" - :expanded="!discussion.diff_file.viewer.collapsed" + :expanded="!discussion.diff_file.viewer.automaticallyCollapsed" /> <div v-if="isTextFile" class="diff-content"> <table class="code js-syntax-highlight" :class="$options.userColorSchemeClass"> diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index c6fab271376..2427a3f98ad 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,6 +1,7 @@ <script> import { mapGetters, mapActions } from 'vuex'; -import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlButton, GlButtonGroup } from '@gitlab/ui'; +import { __ } from '~/locale'; import discussionNavigation from '../mixins/discussion_navigation'; export default { @@ -9,6 +10,8 @@ export default { }, components: { GlIcon, + GlButton, + GlButtonGroup, }, mixins: [discussionNavigation], computed: { @@ -34,6 +37,12 @@ export default { allExpanded() { return this.toggeableDiscussions.every(discussion => discussion.expanded); }, + lineResolveClass() { + return this.allResolved ? 'line-resolve-btn is-active' : 'line-resolve-text'; + }, + toggleThreadsLabel() { + return this.allExpanded ? __('Collapse all threads') : __('Expand all threads'); + }, }, methods: { ...mapActions(['setExpandDiscussions']), @@ -51,59 +60,49 @@ export default { <div v-if="resolvableDiscussionsCount > 0" ref="discussionCounter" - class="line-resolve-all-container full-width-mobile" + class="line-resolve-all-container full-width-mobile gl-display-flex d-sm-flex" > - <div class="full-width-mobile d-flex d-sm-flex"> - <div class="line-resolve-all"> - <span - :class="{ 'line-resolve-btn is-active': allResolved, 'line-resolve-text': !allResolved }" - > - <template v-if="allResolved"> - <gl-icon name="check-circle-filled" /> - {{ __('All threads resolved') }} - </template> - <template v-else> - {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }} - </template> - </span> - </div> - <div - v-if="resolveAllDiscussionsIssuePath && !allResolved" - class="btn-group btn-group-sm" - role="group" - > - <a - v-gl-tooltip - :href="resolveAllDiscussionsIssuePath" - :title="s__('Resolve all threads in new issue')" - class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" - > - <gl-icon name="issue-new" /> - </a> - </div> - <div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group"> - <button - v-gl-tooltip - :title="__('Jump to next unresolved thread')" - class="btn btn-default discussion-next-btn" - data-track-event="click_button" - data-track-label="mr_next_unresolved_thread" - data-track-property="click_next_unresolved_thread_top" - @click="jumpToNextDiscussion" - > - <gl-icon name="comment-next" /> - </button> - </div> - <div class="btn-group btn-group-sm" role="group"> - <button - v-gl-tooltip - :title="__('Toggle all threads')" - class="btn btn-default toggle-all-discussions-btn" - @click="handleExpandDiscussions" - > - <gl-icon :name="allExpanded ? 'angle-up' : 'angle-down'" /> - </button> - </div> + <div class="line-resolve-all"> + <span :class="lineResolveClass"> + <template v-if="allResolved"> + <gl-icon name="check-circle-filled" /> + {{ __('All threads resolved') }} + </template> + <template v-else> + {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }} + </template> + </span> </div> + <gl-button-group> + <gl-button + v-if="resolveAllDiscussionsIssuePath && !allResolved" + v-gl-tooltip + :href="resolveAllDiscussionsIssuePath" + :title="s__('Resolve all threads in new issue')" + :aria-label="s__('Resolve all threads in new issue')" + class="new-issue-for-discussion discussion-create-issue-btn" + icon="issue-new" + /> + <gl-button + v-if="isLoggedIn && !allResolved" + v-gl-tooltip + :title="__('Jump to next unresolved thread')" + :aria-label="__('Jump to next unresolved thread')" + class="discussion-next-btn" + data-track-event="click_button" + data-track-label="mr_next_unresolved_thread" + data-track-property="click_next_unresolved_thread_top" + icon="comment-next" + @click="jumpToNextDiscussion" + /> + <gl-button + v-gl-tooltip + :title="toggleThreadsLabel" + :aria-label="toggleThreadsLabel" + class="toggle-all-discussions-btn" + :icon="allExpanded ? 'angle-up' : 'angle-down'" + @click="handleExpandDiscussions" + /> + </gl-button-group> </div> </template> diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 989ce9ff144..e4b191b55a7 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -1,11 +1,11 @@ <script> -import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; -import { GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; import { DISCUSSION_FILTERS_DEFAULT_VALUE, HISTORY_ONLY_FILTER_VALUE, + COMMENTS_ONLY_FILTER_VALUE, DISCUSSION_TAB_LABEL, DISCUSSION_FILTER_TYPES, NOTE_UNDERSCORE, @@ -14,7 +14,9 @@ import notesEventHub from '../event_hub'; export default { components: { - GlIcon, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, }, props: { filters: { @@ -37,7 +39,7 @@ export default { }; }, computed: { - ...mapGetters(['getNotesDataByProp']), + ...mapGetters(['getNotesDataByProp', 'timelineEnabled']), currentFilter() { if (!this.currentValue) return this.filters[0]; return this.filters.find(filter => filter.value === this.currentValue); @@ -62,14 +64,20 @@ export default { window.removeEventListener('hashchange', this.handleLocationHash); }, methods: { - ...mapActions(['filterDiscussion', 'setCommentsDisabled', 'setTargetNoteHash']), + ...mapActions([ + 'filterDiscussion', + 'setCommentsDisabled', + 'setTargetNoteHash', + 'setTimelineView', + ]), selectFilter(value, persistFilter = true) { const filter = parseInt(value, 10); - // close dropdown - this.toggleDropdown(); - if (filter === this.currentValue) return; + + if (this.timelineEnabled && filter !== COMMENTS_ONLY_FILTER_VALUE) { + this.setTimelineView(false); + } this.currentValue = filter; this.filterDiscussion({ path: this.getNotesDataByProp('discussionsPath'), @@ -78,9 +86,6 @@ export default { }); this.toggleCommentsForm(); }, - toggleDropdown() { - $(this.$refs.dropdownToggle).dropdown('toggle'); - }, toggleCommentsForm() { this.setCommentsDisabled(this.currentValue === HISTORY_ONLY_FILTER_VALUE); }, @@ -92,7 +97,6 @@ export default { if (/^note_/.test(hash) && this.currentValue !== DISCUSSION_FILTERS_DEFAULT_VALUE) { this.selectFilter(this.defaultValue, false); - this.toggleDropdown(); // close dropdown this.setTargetNoteHash(hash); } }, @@ -109,43 +113,24 @@ export default { </script> <template> - <div + <gl-dropdown v-if="displayFilters" - class="discussion-filter-container js-discussion-filter-container d-inline-block align-bottom full-width-mobile" + id="discussion-filter-dropdown" + class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container qa-discussion-filter" + :text="currentFilter.title" > - <button - id="discussion-filter-dropdown" - ref="dropdownToggle" - class="btn btn-sm qa-discussion-filter" - data-toggle="dropdown" - aria-expanded="false" - > - {{ currentFilter.title }} <gl-icon name="chevron-down" /> - </button> - <div - ref="dropdownMenu" - class="dropdown-menu dropdown-menu-selectable dropdown-menu-right" - aria-labelledby="discussion-filter-dropdown" - > - <div class="dropdown-content"> - <ul> - <li - v-for="filter in filters" - :key="filter.value" - :data-filter-type="filterType(filter.value)" - > - <button - :class="{ 'is-active': filter.value === currentValue }" - class="qa-filter-options" - type="button" - @click="selectFilter(filter.value)" - > - {{ filter.title }} - </button> - <div v-if="filter.value === defaultValue" class="dropdown-divider"></div> - </li> - </ul> - </div> + <div v-for="filter in filters" :key="filter.value" class="dropdown-item-wrapper"> + <gl-dropdown-item + :is-check-item="true" + :is-checked="filter.value === currentValue" + :class="{ 'is-active': filter.value === currentValue }" + :data-filter-type="filterType(filter.value)" + class="qa-filter-options" + @click.prevent="selectFilter(filter.value)" + > + {{ filter.title }} + </gl-dropdown-item> + <gl-dropdown-divider v-if="filter.value === defaultValue" /> </div> - </div> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index a8057276f1a..c2f40b2d21a 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -160,7 +160,7 @@ export default { }); }, displayMemberBadgeText() { - return sprintf(__('This user is a %{access} of the %{name} project.'), { + return sprintf(__('This user has the %{access} role in the %{name} project.'), { access: this.accessLevel.toLowerCase(), name: this.projectName, }); @@ -275,7 +275,8 @@ export default { v-gl-tooltip type="button" title="Edit comment" - class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button" + class="note-action-button js-note-edit btn btn-transparent" + data-qa-selector="note_edit_button" @click="onEdit" > <gl-icon name="pencil" class="link-highlight" /> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 314fa762768..65b89b94eaa 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -45,7 +45,7 @@ export default { }, }, computed: { - ...mapGetters(['getDiscussion']), + ...mapGetters(['getDiscussion', 'suggestionsCount']), discussion() { if (!this.note.isDraft) return {}; @@ -125,6 +125,7 @@ export default { <suggestions v-if="hasSuggestion && !isEditing" :suggestions="note.suggestions" + :suggestions-count="suggestionsCount" :batch-suggestions-info="batchSuggestionsInfo" :note-html="note.note_html" :line-type="lineType" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 88b4461cf38..4b3f23e742d 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -328,6 +328,7 @@ export default { :add-spacing-classes="false" :help-page-path="helpPagePath" :show-suggest-popover="showSuggestPopover" + :textarea-value="updatedNoteBody" @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" > <textarea @@ -337,7 +338,8 @@ export default { v-model="updatedNoteBody" :data-supports-quick-actions="!isEditing" name="note[note]" - class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form qa-reply-input" + class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form" + data-qa-selector="reply_field" dir="auto" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" @@ -376,7 +378,8 @@ export default { <button :disabled="isDisabled" type="button" - class="btn btn-success qa-start-review" + class="btn btn-success" + data-qa-selector="start_review_button" @click="handleAddToReview" > <template v-if="hasDrafts">{{ __('Add to review') }}</template> @@ -385,7 +388,8 @@ export default { <button :disabled="isDisabled" type="button" - class="btn qa-comment-now js-comment-button" + class="btn js-comment-button" + data-qa-selector="comment_now_button" @click="handleUpdate()" > {{ __('Add comment now') }} @@ -404,7 +408,8 @@ export default { <button :disabled="isDisabled" type="button" - class="js-vue-issue-save btn btn-success js-comment-button qa-reply-comment-button" + class="js-vue-issue-save btn btn-success js-comment-button" + data-qa-selector="reply_comment_button" @click="handleUpdate()" > {{ saveButtonTitle }} diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index fb18be9386e..9eaa4e422d5 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -73,6 +73,7 @@ export default { 'userCanReply', 'discussionTabCounter', 'sortDirection', + 'timelineEnabled', ]), sortDirDesc() { return this.sortDirection === constants.DESC; @@ -95,7 +96,7 @@ export default { return this.discussions; }, canReply() { - return this.userCanReply && !this.commentsDisabled; + return this.userCanReply && !this.commentsDisabled && !this.timelineEnabled; }, slotKeys() { return this.sortDirDesc ? ['form', 'comments'] : ['comments', 'form']; @@ -252,7 +253,7 @@ export default { <ordered-layout :slot-keys="slotKeys"> <template #form> <comment-form - v-if="!commentsDisabled" + v-if="!(commentsDisabled || timelineEnabled)" class="js-comment-form" :noteable-type="noteableType" /> diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue index 60b531d7597..c279a7107c7 100644 --- a/app/assets/javascripts/notes/components/sort_discussion.vue +++ b/app/assets/javascripts/notes/components/sort_discussion.vue @@ -1,6 +1,5 @@ -gs <script> -import { GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { mapActions, mapGetters } from 'vuex'; import { __ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; @@ -15,12 +14,13 @@ const SORT_OPTIONS = [ export default { SORT_OPTIONS, components: { - GlIcon, + GlDropdown, + GlDropdownItem, LocalStorageSync, }, mixins: [Tracking.mixin()], computed: { - ...mapGetters(['sortDirection', 'noteableType']), + ...mapGetters(['sortDirection', 'persistSortOrder', 'noteableType']), selectedOption() { return SORT_OPTIONS.find(({ key }) => this.sortDirection === key); }, @@ -38,7 +38,7 @@ export default { return; } - this.setDiscussionSortDirection(direction); + this.setDiscussionSortDirection({ direction }); this.track('change_discussion_sort_direction', { property: direction }); }, isDropdownItemActive(sortDir) { @@ -49,33 +49,28 @@ export default { </script> <template> - <div - data-testid="sort-discussion-filter" - class="gl-mr-2 gl-display-inline-block gl-vertical-align-bottom full-width-mobile" - > + <div class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile"> <local-storage-sync :value="sortDirection" :storage-key="storageKey" - @input="setDiscussionSortDirection" + :persist="persistSortOrder" + @input="setDiscussionSortDirection({ direction: $event })" /> - <button class="btn btn-sm js-dropdown-text" data-toggle="dropdown" aria-expanded="false"> - {{ dropdownText }} - <gl-icon name="chevron-down" /> - </button> - <div ref="dropdownMenu" class="dropdown-menu dropdown-menu-selectable dropdown-menu-right"> - <div class="dropdown-content"> - <ul> - <li v-for="{ text, key, cls } in $options.SORT_OPTIONS" :key="key"> - <button - :class="[cls, { 'is-active': isDropdownItemActive(key) }]" - type="button" - @click="fetchSortedDiscussions(key)" - > - {{ text }} - </button> - </li> - </ul> - </div> - </div> + <gl-dropdown + :text="dropdownText" + data-testid="sort-discussion-filter" + class="js-dropdown-text full-width-mobile" + > + <gl-dropdown-item + v-for="{ text, key, cls } in $options.SORT_OPTIONS" + :key="key" + :class="cls" + :is-check-item="true" + :is-checked="isDropdownItemActive(key)" + @click="fetchSortedDiscussions(key)" + > + {{ text }} + </gl-dropdown-item> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/notes/components/timeline_toggle.vue b/app/assets/javascripts/notes/components/timeline_toggle.vue new file mode 100644 index 00000000000..d1ffe0a3601 --- /dev/null +++ b/app/assets/javascripts/notes/components/timeline_toggle.vue @@ -0,0 +1,60 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { mapActions, mapGetters } from 'vuex'; +import { s__ } from '~/locale'; +import { COMMENTS_ONLY_FILTER_VALUE, DESC } from '../constants'; +import notesEventHub from '../event_hub'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import { trackToggleTimelineView } from '../utils'; + +export const timelineEnabledTooltip = s__('Timeline|Turn timeline view off'); +export const timelineDisabledTooltip = s__('Timeline|Turn timeline view on'); + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + TrackEvent: TrackEventDirective, + }, + computed: { + ...mapGetters(['timelineEnabled', 'sortDirection']), + tooltip() { + return this.timelineEnabled ? timelineEnabledTooltip : timelineDisabledTooltip; + }, + }, + methods: { + ...mapActions(['setTimelineView', 'setDiscussionSortDirection']), + trackToggleTimelineView, + setSort() { + if (this.timelineEnabled && this.sortDirection !== DESC) { + this.setDiscussionSortDirection({ direction: DESC, persist: false }); + } + }, + setFilter() { + notesEventHub.$emit('dropdownSelect', COMMENTS_ONLY_FILTER_VALUE, false); + }, + toggleTimeline(event) { + event.currentTarget.blur(); + this.setTimelineView(!this.timelineEnabled); + this.setSort(); + this.setFilter(); + }, + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip + v-track-event="trackToggleTimelineView(timelineEnabled)" + icon="comments" + size="small" + :selected="timelineEnabled" + :title="tooltip" + :aria-label="tooltip" + class="gl-mr-3" + @click="toggleTimeline" + /> +</template> diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index bddac60647d..f49fd2c3fa3 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -57,7 +57,12 @@ export default { tooltip-placement="bottom" /> </div> - <button class="btn btn-link js-replies-text qa-expand-replies" type="button" @click="toggle"> + <button + class="btn btn-link js-replies-text" + data-qa-selector="expand_replies_button" + type="button" + @click="toggle" + > {{ replies.length }} {{ n__('reply', 'replies', replies.length) }} </button> {{ __('Last reply by') }} @@ -68,7 +73,8 @@ export default { </template> <span v-else - class="collapse-replies-btn js-collapse-replies qa-collapse-replies" + class="collapse-replies-btn js-collapse-replies" + data-qa-selector="collapse_replies_button" @click="toggle" > <gl-icon name="chevron-down" /> {{ s__('Notes|Collapse replies') }} diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index b81aae7c257..7acf2ad57c8 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -14,8 +14,9 @@ export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest'; export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const RESOLVE_NOTE_METHOD_NAME = 'post'; export const DESCRIPTION_TYPE = 'changed the description'; -export const HISTORY_ONLY_FILTER_VALUE = 2; export const DISCUSSION_FILTERS_DEFAULT_VALUE = 0; +export const COMMENTS_ONLY_FILTER_VALUE = 1; +export const HISTORY_ONLY_FILTER_VALUE = 2; export const DISCUSSION_TAB_LABEL = 'show'; export const NOTE_UNDERSCORE = 'note_'; export const TIME_DIFFERENCE_VALUE = 10; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 7bf465482b3..1f0b2afab9e 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -2,18 +2,21 @@ import Vue from 'vue'; import notesApp from './components/notes_app.vue'; import initDiscussionFilters from './discussion_filters'; import initSortDiscussions from './sort_discussions'; +import initTimelineToggle from './timeline'; import { store } from './stores'; -document.addEventListener('DOMContentLoaded', () => { +const el = document.getElementById('js-vue-notes'); + +if (el) { // eslint-disable-next-line no-new new Vue({ - el: '#js-vue-notes', + el, components: { notesApp, }, store, data() { - const notesDataset = document.getElementById('js-vue-notes').dataset; + const notesDataset = el.dataset; const parsedUserData = JSON.parse(notesDataset.currentUserData); const noteableData = JSON.parse(notesDataset.noteableData); let currentUserData = {}; @@ -55,4 +58,5 @@ document.addEventListener('DOMContentLoaded', () => { initDiscussionFilters(store); initSortDiscussions(store); -}); + initTimelineToggle(store); +} diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 9c63a7e3cd4..37986c8a02d 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -99,8 +99,12 @@ export const updateDiscussion = ({ commit, state }, discussion) => { return utils.findNoteObjectById(state.discussions, discussion.id); }; -export const setDiscussionSortDirection = ({ commit }, direction) => { - commit(types.SET_DISCUSSIONS_SORT, direction); +export const setDiscussionSortDirection = ({ commit }, { direction, persist = true }) => { + commit(types.SET_DISCUSSIONS_SORT, { direction, persist }); +}; + +export const setTimelineView = ({ commit }, enabled) => { + commit(types.SET_TIMELINE_VIEW, enabled); }; export const setSelectedCommentPosition = ({ commit }, position) => { diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 7d60fbffb10..5b3ffa425a0 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -5,6 +5,23 @@ import { collapseSystemNotes } from './collapse_utils'; export const discussions = state => { let discussionsInState = clone(state.discussions); // NOTE: not testing bc will be removed when backend is finished. + + if (state.isTimelineEnabled) { + discussionsInState = discussionsInState + .reduce((acc, discussion) => { + const transformedToIndividualNotes = discussion.notes.map(note => ({ + ...discussion, + id: note.id, + created_at: note.created_at, + individual_note: true, + notes: [note], + })); + + return acc.concat(transformedToIndividualNotes); + }, []) + .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + } + if (state.discussionSortOrder === constants.DESC) { discussionsInState = discussionsInState.reverse(); } @@ -27,6 +44,10 @@ export const isNotesFetched = state => state.isNotesFetched; export const sortDirection = state => state.discussionSortOrder; +export const persistSortOrder = state => state.persistSortOrder; + +export const timelineEnabled = state => state.isTimelineEnabled; + export const isLoading = state => state.isLoading; export const getNotesDataByProp = state => prop => state.notesData[prop]; @@ -231,3 +252,6 @@ export const getDiscussion = state => discussionId => state.discussions.find(discussion => discussion.id === discussionId); export const commentsDisabled = state => state.commentsDisabled; + +export const suggestionsCount = (state, getters) => + Object.values(getters.notesById).filter(n => n.suggestions.length).length; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 161c9b8b1b5..a8738fa7c5f 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -7,6 +7,7 @@ export default () => ({ state: { discussions: [], discussionSortOrder: ASC, + persistSortOrder: true, convertedDisscussionIds: [], targetNoteHash: null, lastFetchedAt: null, @@ -45,6 +46,7 @@ export default () => ({ resolvableDiscussionsCount: 0, unresolvedDiscussionsCount: 0, descriptionVersions: {}, + isTimelineEnabled: false, }, actions, getters, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 23515cdd9e3..7496dd630f6 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -34,6 +34,7 @@ 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_TIMELINE_VIEW = 'SET_TIMELINE_VIEW'; export const SET_SELECTED_COMMENT_POSITION = 'SET_SELECTED_COMMENT_POSITION'; export const SET_SELECTED_COMMENT_POSITION_HOVER = 'SET_SELECTED_COMMENT_POSITION_HOVER'; export const SET_FETCHING_DISCUSSIONS = 'SET_FETCHING_DISCUSSIONS'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index a8bd94cc763..6c11d53dba3 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -313,8 +313,13 @@ export default { discussion.truncated_diff_lines = utils.prepareDiffLines(diffLines); }, - [types.SET_DISCUSSIONS_SORT](state, sort) { - state.discussionSortOrder = sort; + [types.SET_DISCUSSIONS_SORT](state, { direction, persist }) { + state.discussionSortOrder = direction; + state.persistSortOrder = persist; + }, + + [types.SET_TIMELINE_VIEW](state, value) { + state.isTimelineEnabled = value; }, [types.SET_SELECTED_COMMENT_POSITION](state, position) { diff --git a/app/assets/javascripts/notes/timeline.js b/app/assets/javascripts/notes/timeline.js new file mode 100644 index 00000000000..df6d1b21400 --- /dev/null +++ b/app/assets/javascripts/notes/timeline.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import TimelineToggle from './components/timeline_toggle.vue'; + +export default function initTimelineToggle(store) { + const el = document.getElementById('js-incidents-timeline-toggle'); + + if (!el) return null; + + return new Vue({ + el, + store, + render(createElement) { + return createElement(TimelineToggle); + }, + }); +} diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js new file mode 100644 index 00000000000..e6c2eb06a51 --- /dev/null +++ b/app/assets/javascripts/notes/utils.js @@ -0,0 +1,12 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +/** + * Tracks snowplow event when User toggles timeline view + * @param {Boolean} enabled that will be send as a property for the event + */ +export const trackToggleTimelineView = enabled => ({ + category: 'Incident Management', + action: 'toggle_incident_comments_into_timeline_view', + label: 'Status', + property: enabled, +}); diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js index 47fb5b271d1..ae992dd5dc5 100644 --- a/app/assets/javascripts/notifications_dropdown.js +++ b/app/assets/javascripts/notifications_dropdown.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { Rails } from '~/lib/utils/rails_ujs'; import { deprecatedCreateFlash as Flash } from './flash'; import { __ } from '~/locale'; @@ -21,10 +22,12 @@ export default function notificationsDropdown() { form.find('.js-notifications-icon').toggleClass('hidden'); } form.find('#notification_setting_level').val(notificationLevel); - form.submit(); + Rails.fire(form[0], 'submit'); }); - $(document).on('ajax:success', '.notification-form', (e, data) => { + $(document).on('ajax:success', '.notification-form', e => { + const data = e.detail[0]; + if (data.saved) { $(e.currentTarget) .closest('.js-notification-dropdown') diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue index 9df6a412930..2e972dd7154 100644 --- a/app/assets/javascripts/operation_settings/components/metrics_settings.vue +++ b/app/assets/javascripts/operation_settings/components/metrics_settings.vue @@ -44,11 +44,9 @@ export default { <form> <dashboard-timezone /> <external-dashboard /> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button variant="success" category="primary" @click="saveChanges"> - {{ __('Save Changes') }} - </gl-button> - </div> + <gl-button variant="success" category="primary" @click="saveChanges"> + {{ __('Save Changes') }} + </gl-button> </form> </div> </section> diff --git a/app/assets/javascripts/packages/details/components/additional_metadata.vue b/app/assets/javascripts/packages/details/components/additional_metadata.vue index 76e0976ac05..4e99099b0a1 100644 --- a/app/assets/javascripts/packages/details/components/additional_metadata.vue +++ b/app/assets/javascripts/packages/details/components/additional_metadata.vue @@ -2,7 +2,6 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; -import { generateConanRecipe } from '../utils'; import { PackageType } from '../../shared/constants'; export default { @@ -25,9 +24,6 @@ export default { }, }, computed: { - conanRecipe() { - return generateConanRecipe(this.packageEntity); - }, showMetadata() { const visibilityConditions = { [PackageType.NUGET]: this.packageEntity.nuget_metadatum, @@ -73,7 +69,7 @@ export default { data-testid="conan-recipe" > <gl-sprintf :message="$options.i18n.recipeText"> - <template #recipe>{{ conanRecipe }}</template> + <template #recipe>{{ packageEntity.name }}</template> </gl-sprintf> </details-row> diff --git a/app/assets/javascripts/packages/details/components/composer_installation.vue b/app/assets/javascripts/packages/details/components/composer_installation.vue index 60ad468c293..9d87ae8f836 100644 --- a/app/assets/javascripts/packages/details/components/composer_installation.vue +++ b/app/assets/javascripts/packages/details/components/composer_installation.vue @@ -14,12 +14,12 @@ export default { }, computed: { ...mapState(['composerHelpPath']), - ...mapGetters(['composerRegistryInclude', 'composerPackageInclude']), + ...mapGetters(['composerRegistryInclude', 'composerPackageInclude', 'groupExists']), }, i18n: { - registryInclude: s__('PackageRegistry|composer.json registry include'), + registryInclude: s__('PackageRegistry|Add composer registry'), copyRegistryInclude: s__('PackageRegistry|Copy registry include'), - packageInclude: s__('PackageRegistry|composer.json require package include'), + packageInclude: s__('PackageRegistry|Install package version'), copyPackageInclude: s__('PackageRegistry|Copy require package include'), infoLine: s__( 'PackageRegistry|For more information on Composer packages in GitLab, %{linkStart}see the documentation.%{linkEnd}', @@ -31,7 +31,7 @@ export default { </script> <template> - <div> + <div v-if="groupExists" data-testid="root-node"> <h3 class="gl-font-lg">{{ __('Installation') }}</h3> <code-instruction diff --git a/app/assets/javascripts/packages/details/components/package_title.vue b/app/assets/javascripts/packages/details/components/package_title.vue index 69dd494f11a..2789be30818 100644 --- a/app/assets/javascripts/packages/details/components/package_title.vue +++ b/app/assets/javascripts/packages/details/components/package_title.vue @@ -54,15 +54,15 @@ export default { </gl-sprintf> </template> - <template v-if="packageTypeDisplay" #metadata_type> + <template v-if="packageTypeDisplay" #metadata-type> <metadata-item data-testid="package-type" icon="package" :text="packageTypeDisplay" /> </template> - <template #metadata_size> + <template #metadata-size> <metadata-item data-testid="package-size" icon="disk" :text="totalSize" /> </template> - <template v-if="packagePipeline" #metadata_pipeline> + <template v-if="packagePipeline" #metadata-pipeline> <metadata-item data-testid="pipeline-project" icon="review-list" @@ -71,11 +71,11 @@ export default { /> </template> - <template v-if="packagePipeline" #metadata_ref> + <template v-if="packagePipeline" #metadata-ref> <metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" /> </template> - <template v-if="hasTagsToDisplay" #metadata_tags> + <template v-if="hasTagsToDisplay" #metadata-tags> <package-tags :tag-display-limit="2" :tags="packageEntity.tags" hide-label /> </template> diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js index ede6d39bde7..14e76ac84bd 100644 --- a/app/assets/javascripts/packages/details/store/getters.js +++ b/app/assets/javascripts/packages/details/store/getters.js @@ -1,4 +1,3 @@ -import { generateConanRecipe } from '../utils'; import { PackageType } from '../../shared/constants'; import { getPackageTypeLabel } from '../../shared/utils'; import { NpmManager } from '../constants'; @@ -20,10 +19,8 @@ export const packageIcon = ({ packageEntity }) => { }; export const conanInstallationCommand = ({ packageEntity }) => { - const recipe = generateConanRecipe(packageEntity); - // eslint-disable-next-line @gitlab/require-i18n-strings - return `conan install ${recipe} --remote=gitlab`; + return `conan install ${packageEntity.name} --remote=gitlab`; }; export const conanSetupCommand = ({ conanPath }) => @@ -98,18 +95,19 @@ export const nugetSetupCommand = ({ nugetPath }) => export const pypiPipCommand = ({ pypiPath, packageEntity }) => // eslint-disable-next-line @gitlab/require-i18n-strings - `pip install ${packageEntity.name} --index-url ${pypiPath}`; + `pip install ${packageEntity.name} --extra-index-url ${pypiPath}`; export const pypiSetupCommand = ({ pypiSetupPath }) => `[gitlab] repository = ${pypiSetupPath} username = __token__ password = <your personal access token>`; -export const composerRegistryInclude = ({ composerPath }) => { - const base = { type: 'composer', url: composerPath }; - return JSON.stringify(base); -}; -export const composerPackageInclude = ({ packageEntity }) => { - const base = { [packageEntity.name]: packageEntity.version }; - return JSON.stringify(base); -}; +export const composerRegistryInclude = ({ composerPath, composerConfigRepositoryName }) => + // eslint-disable-next-line @gitlab/require-i18n-strings + `composer config repositories.${composerConfigRepositoryName} '{"type": "composer", "url": "${composerPath}"}'`; + +export const composerPackageInclude = ({ packageEntity }) => + // eslint-disable-next-line @gitlab/require-i18n-strings + `composer req ${[packageEntity.name]}:${packageEntity.version}`; + +export const groupExists = ({ groupListUrl }) => groupListUrl.length > 0; diff --git a/app/assets/javascripts/packages/details/utils.js b/app/assets/javascripts/packages/details/utils.js index 454c83c9ccd..27cc95566d3 100644 --- a/app/assets/javascripts/packages/details/utils.js +++ b/app/assets/javascripts/packages/details/utils.js @@ -8,16 +8,3 @@ export const trackInstallationTabChange = { }, }, }; - -export function generateConanRecipe(packageEntity = {}) { - const { - name = '', - version = '', - conan_metadatum: { - package_username: packageUsername = '', - package_channel: packageChannel = '', - } = {}, - } = packageEntity; - - return `${name}/${version}@${packageUsername}/${packageChannel}`; -} diff --git a/app/assets/javascripts/packages/list/coming_soon/helpers.js b/app/assets/javascripts/packages/list/coming_soon/helpers.js deleted file mode 100644 index 5b6a4b3aa87..00000000000 --- a/app/assets/javascripts/packages/list/coming_soon/helpers.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Context: - * https://gitlab.com/gitlab-org/gitlab/-/issues/198524 - * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29491 - * - */ - -/** - * Constants - * - * LABEL_NAMES - an array of labels to filter issues in the GraphQL query - * WORKFLOW_PREFIX - the prefix for workflow labels - * ACCEPTING_CONTRIBUTIONS_TITLE - the accepting contributions label - */ -export const LABEL_NAMES = ['Package::Coming soon']; -const WORKFLOW_PREFIX = 'workflow::'; -const ACCEPTING_CONTRIBUTIONS_TITLE = 'accepting merge requests'; - -const setScoped = (label, scoped) => (label ? { ...label, scoped } : label); - -/** - * Finds workflow:: scoped labels and returns the first or null. - * @param {Object[]} labels Labels from the issue - */ -export const findWorkflowLabel = (labels = []) => - labels.find(l => l.title.toLowerCase().includes(WORKFLOW_PREFIX.toLowerCase())); - -/** - * Determines if an issue is accepting community contributions by checking if - * the "Accepting merge requests" label is present. - * @param {Object[]} labels - */ -export const findAcceptingContributionsLabel = (labels = []) => - labels.find(l => l.title.toLowerCase() === ACCEPTING_CONTRIBUTIONS_TITLE.toLowerCase()); - -/** - * Formats the GraphQL response into the format that the view template expects. - * @param {Object} data GraphQL response - */ -export const toViewModel = data => { - // This just flatterns the issues -> nodes and labels -> nodes hierarchy - // into an array of objects. - const issues = (data.project?.issues?.nodes || []).map(i => ({ - ...i, - labels: (i.labels?.nodes || []).map(node => node), - })); - - return issues.map(x => ({ - ...x, - labels: [ - setScoped(findWorkflowLabel(x.labels), true), - setScoped(findAcceptingContributionsLabel(x.labels), false), - ].filter(Boolean), - })); -}; diff --git a/app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue b/app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue deleted file mode 100644 index 766402d3619..00000000000 --- a/app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue +++ /dev/null @@ -1,172 +0,0 @@ -<script> -import { - GlAlert, - GlEmptyState, - GlIcon, - GlLabel, - GlLink, - GlSkeletonLoader, - GlSprintf, -} from '@gitlab/ui'; -import { ApolloQuery } from 'vue-apollo'; -import Tracking from '~/tracking'; -import { TrackingActions } from '../../shared/constants'; -import { s__ } from '~/locale'; -import comingSoonIssuesQuery from './queries/issues.graphql'; -import { toViewModel, LABEL_NAMES } from './helpers'; - -export default { - name: 'ComingSoon', - components: { - GlAlert, - GlEmptyState, - GlIcon, - GlLabel, - GlLink, - GlSkeletonLoader, - GlSprintf, - ApolloQuery, - }, - mixins: [Tracking.mixin()], - props: { - illustration: { - type: String, - required: true, - }, - projectPath: { - type: String, - required: true, - }, - suggestedContributionsPath: { - type: String, - required: true, - }, - }, - computed: { - variables() { - return { - projectPath: this.projectPath, - labelNames: LABEL_NAMES, - }; - }, - }, - mounted() { - this.track(TrackingActions.COMING_SOON_REQUESTED); - }, - methods: { - onIssueLinkClick(issueIid, label) { - this.track(TrackingActions.COMING_SOON_LIST, { - label, - value: issueIid, - }); - }, - onDocsLinkClick() { - this.track(TrackingActions.COMING_SOON_HELP); - }, - }, - loadingRows: 5, - i18n: { - alertTitle: s__('PackageRegistry|Upcoming package managers'), - alertIntro: s__( - "PackageRegistry|Is your favorite package manager missing? We'd love your help in building first-class support for it into GitLab! %{contributionLinkStart}Visit the contribution documentation%{contributionLinkEnd} to learn more about how to build support for new package managers into GitLab. Below is a list of package managers that are on our radar.", - ), - emptyStateTitle: s__('PackageRegistry|No upcoming issues'), - emptyStateDescription: s__('PackageRegistry|There are no upcoming issues to display.'), - }, - comingSoonIssuesQuery, - toViewModel, -}; -</script> - -<template> - <apollo-query - :query="$options.comingSoonIssuesQuery" - :variables="variables" - :update="$options.toViewModel" - > - <template #default="{ result: { data }, isLoading }"> - <div> - <gl-alert :title="$options.i18n.alertTitle" :dismissible="false" variant="tip"> - <gl-sprintf :message="$options.i18n.alertIntro"> - <template #contributionLink="{ content }"> - <gl-link - :href="suggestedContributionsPath" - target="_blank" - @click="onDocsLinkClick" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> - </gl-alert> - </div> - - <div v-if="isLoading" class="gl-display-flex gl-flex-direction-column"> - <gl-skeleton-loader - v-for="index in $options.loadingRows" - :key="index" - :width="1000" - :height="80" - preserve-aspect-ratio="xMinYMax meet" - > - <rect width="700" height="10" x="0" y="16" rx="4" /> - <rect width="60" height="10" x="0" y="45" rx="4" /> - <rect width="60" height="10" x="70" y="45" rx="4" /> - </gl-skeleton-loader> - </div> - - <template v-else-if="data && data.length"> - <div - v-for="issue in data" - :key="issue.iid" - data-testid="issue-row" - class="gl-responsive-table-row gl-flex-direction-column gl-align-items-baseline" - > - <div class="table-section section-100 gl-white-space-normal text-truncate"> - <gl-link - data-testid="issue-title-link" - :href="issue.webUrl" - class="gl-text-gray-900 gl-font-weight-bold" - @click="onIssueLinkClick(issue.iid, issue.title)" - > - {{ issue.title }} - </gl-link> - </div> - - <div class="table-section section-100 gl-white-space-normal mt-md-3"> - <div class="gl-display-flex gl-text-gray-400"> - <gl-icon name="issues" class="gl-mr-2" /> - <gl-link - data-testid="issue-id-link" - :href="issue.webUrl" - class="gl-text-gray-400 gl-mr-5" - @click="onIssueLinkClick(issue.iid, issue.title)" - >#{{ issue.iid }}</gl-link - > - - <div v-if="issue.milestone" class="gl-display-flex gl-align-items-center gl-mr-5"> - <gl-icon name="clock" class="gl-mr-2" /> - <span data-testid="milestone">{{ issue.milestone.title }}</span> - </div> - - <gl-label - v-for="label in issue.labels" - :key="label.title" - class="gl-mr-3" - size="sm" - :background-color="label.color" - :title="label.title" - :scoped="Boolean(label.scoped)" - /> - </div> - </div> - </div> - </template> - - <gl-empty-state v-else :title="$options.i18n.emptyStateTitle" :svg-path="illustration"> - <template #description> - <p>{{ $options.i18n.emptyStateDescription }}</p> - </template> - </gl-empty-state> - </template> - </apollo-query> -</template> diff --git a/app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql b/app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql deleted file mode 100644 index 36c27d9ad70..00000000000 --- a/app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql +++ /dev/null @@ -1,20 +0,0 @@ -query getComingSoonIssues($projectPath: ID!, $labelNames: [String]) { - project(fullPath: $projectPath) { - issues(state: opened, labelName: $labelNames) { - nodes { - iid - title - webUrl - labels { - nodes { - title - color - } - } - milestone { - title - } - } - } - } -} diff --git a/app/assets/javascripts/packages/list/components/package_title.vue b/app/assets/javascripts/packages/list/components/package_title.vue new file mode 100644 index 00000000000..f94a98e4ca7 --- /dev/null +++ b/app/assets/javascripts/packages/list/components/package_title.vue @@ -0,0 +1,47 @@ +<script> +import { n__ } from '~/locale'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '../constants'; + +export default { + name: 'PackageTitle', + components: { + TitleArea, + MetadataItem, + }, + props: { + packagesCount: { + type: Number, + required: false, + default: null, + }, + packageHelpUrl: { + type: String, + required: true, + }, + }, + computed: { + showPackageCount() { + return Number.isInteger(this.packagesCount); + }, + packageAmountText() { + return n__(`%d Package`, `%d Packages`, this.packagesCount); + }, + infoMessages() { + return [{ text: LIST_INTRO_TEXT, link: this.packageHelpUrl }]; + }, + }, + i18n: { + LIST_TITLE_TEXT, + }, +}; +</script> + +<template> + <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages"> + <template #metadata-amount> + <metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" /> + </template> + </title-area> +</template> diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages/list/components/packages_list_app.vue index 6304f723f6a..cbb3bfd35ac 100644 --- a/app/assets/javascripts/packages/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue @@ -3,13 +3,13 @@ import { mapActions, mapState } from 'vuex'; import { GlEmptyState, GlTab, GlTabs, GlLink, GlSprintf } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import createFlash from '~/flash'; +import { historyReplaceState } from '~/lib/utils/common_utils'; +import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; import PackageFilter from './packages_filter.vue'; import PackageList from './packages_list.vue'; import PackageSort from './packages_sort.vue'; import { PACKAGE_REGISTRY_TABS, DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants'; -import PackagesComingSoon from '../coming_soon/packages_coming_soon.vue'; -import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; -import { historyReplaceState } from '~/lib/utils/common_utils'; +import PackageTitle from './package_title.vue'; export default { components: { @@ -21,15 +21,16 @@ export default { PackageFilter, PackageList, PackageSort, - PackagesComingSoon, + PackageTitle, }, computed: { ...mapState({ emptyListIllustration: state => state.config.emptyListIllustration, emptyListHelpUrl: state => state.config.emptyListHelpUrl, - comingSoon: state => state.config.comingSoon, filterQuery: state => state.filterQuery, selectedType: state => state.selectedType, + packageHelpUrl: state => state.config.packageHelpUrl, + packagesCount: state => state.pagination?.total, }), tabsToRender() { return PACKAGE_REGISTRY_TABS; @@ -89,39 +90,35 @@ export default { </script> <template> - <gl-tabs @input="tabChanged"> - <template #tabs-end> - <div - class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end" - > - <package-filter class="mr-1" @filter="requestPackagesList" /> - <package-sort @sort:changed="requestPackagesList" /> - </div> - </template> + <div> + <package-title :package-help-url="packageHelpUrl" :packages-count="packagesCount" /> - <gl-tab v-for="(tab, index) in tabsToRender" :key="index" :title="tab.title"> - <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> - <template #empty-state> - <gl-empty-state :title="emptyStateTitle(tab)" :svg-path="emptyListIllustration"> - <template #description> - <gl-sprintf v-if="filterQuery" :message="$options.i18n.widenFilters" /> - <gl-sprintf v-else :message="$options.i18n.noResults"> - <template #noPackagesLink="{content}"> - <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </template> - </gl-empty-state> - </template> - </package-list> - </gl-tab> + <gl-tabs @input="tabChanged"> + <template #tabs-end> + <div + class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end" + > + <package-filter class="gl-mr-2" @filter="requestPackagesList" /> + <package-sort @sort:changed="requestPackagesList" /> + </div> + </template> - <gl-tab v-if="comingSoon" :title="__('Coming soon')" lazy> - <packages-coming-soon - :illustration="emptyListIllustration" - :project-path="comingSoon.projectPath" - :suggested-contributions-path="comingSoon.suggestedContributions" - /> - </gl-tab> - </gl-tabs> + <gl-tab v-for="(tab, index) in tabsToRender" :key="index" :title="tab.title"> + <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> + <template #empty-state> + <gl-empty-state :title="emptyStateTitle(tab)" :svg-path="emptyListIllustration"> + <template #description> + <gl-sprintf v-if="filterQuery" :message="$options.i18n.widenFilters" /> + <gl-sprintf v-else :message="$options.i18n.noResults"> + <template #noPackagesLink="{content}"> + <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-empty-state> + </template> + </package-list> + </gl-tab> + </gl-tabs> + </div> </template> diff --git a/app/assets/javascripts/packages/list/components/packages_sort.vue b/app/assets/javascripts/packages/list/components/packages_sort.vue index fa8f4f39d54..47e51bbdca5 100644 --- a/app/assets/javascripts/packages/list/components/packages_sort.vue +++ b/app/assets/javascripts/packages/list/components/packages_sort.vue @@ -51,7 +51,7 @@ export default { <gl-sorting-item v-for="item in sortableFields" ref="packageListSortItem" - :key="item.key" + :key="item.orderBy" @click="onSortItemClick(item.orderBy)" > {{ item.label }} diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js index 0ff8c86362d..6a0e92bff2d 100644 --- a/app/assets/javascripts/packages/list/constants.js +++ b/app/assets/javascripts/packages/list/constants.js @@ -15,7 +15,7 @@ export const GROUP_PAGE_TYPE = 'groups'; export const LIST_KEY_NAME = 'name'; export const LIST_KEY_PROJECT = 'project_path'; export const LIST_KEY_VERSION = 'version'; -export const LIST_KEY_PACKAGE_TYPE = 'package_type'; +export const LIST_KEY_PACKAGE_TYPE = 'type'; export const LIST_KEY_CREATED_AT = 'created_at'; export const LIST_KEY_ACTIONS = 'actions'; @@ -23,47 +23,35 @@ export const LIST_LABEL_NAME = __('Name'); export const LIST_LABEL_PROJECT = __('Project'); export const LIST_LABEL_VERSION = __('Version'); export const LIST_LABEL_PACKAGE_TYPE = __('Type'); -export const LIST_LABEL_CREATED_AT = __('Created'); +export const LIST_LABEL_CREATED_AT = __('Published'); export const LIST_LABEL_ACTIONS = ''; -export const LIST_ORDER_BY_PACKAGE_TYPE = 'type'; - export const ASCENDING_ODER = 'asc'; export const DESCENDING_ORDER = 'desc'; // The following is not translated because it is used to build a JavaScript exception error message export const MISSING_DELETE_PATH_ERROR = 'Missing delete_api_path link'; -export const TABLE_HEADER_FIELDS = [ +export const SORT_FIELDS = [ { - key: LIST_KEY_NAME, - label: LIST_LABEL_NAME, orderBy: LIST_KEY_NAME, - class: ['text-left'], + label: LIST_LABEL_NAME, }, { - key: LIST_KEY_PROJECT, - label: LIST_LABEL_PROJECT, orderBy: LIST_KEY_PROJECT, - class: ['text-left'], + label: LIST_LABEL_PROJECT, }, { - key: LIST_KEY_VERSION, - label: LIST_LABEL_VERSION, orderBy: LIST_KEY_VERSION, - class: ['text-center'], + label: LIST_LABEL_VERSION, }, { - key: LIST_KEY_PACKAGE_TYPE, + orderBy: LIST_KEY_PACKAGE_TYPE, label: LIST_LABEL_PACKAGE_TYPE, - orderBy: LIST_ORDER_BY_PACKAGE_TYPE, - class: ['text-center'], }, { - key: LIST_KEY_CREATED_AT, - label: LIST_LABEL_CREATED_AT, orderBy: LIST_KEY_CREATED_AT, - class: ['text-center'], + label: LIST_LABEL_CREATED_AT, }, ]; @@ -94,7 +82,13 @@ export const PACKAGE_REGISTRY_TABS = [ type: PackageType.NUGET, }, { - title: s__('PackageRegistry|PyPi'), + title: s__('PackageRegistry|PyPI'), type: PackageType.PYPI, }, ]; + +export const LIST_TITLE_TEXT = s__('PackageRegistry|Package Registry'); + +export const LIST_INTRO_TEXT = s__( + 'PackageRegistry|Publish and share packages for a variety of common package managers. %{docLinkStart}More information%{docLinkEnd}', +); diff --git a/app/assets/javascripts/packages/list/stores/mutations.js b/app/assets/javascripts/packages/list/stores/mutations.js index a47ba356c0a..2fe7981b3d9 100644 --- a/app/assets/javascripts/packages/list/stores/mutations.js +++ b/app/assets/javascripts/packages/list/stores/mutations.js @@ -1,19 +1,12 @@ import * as types from './mutation_types'; -import { - parseIntPagination, - normalizeHeaders, - convertObjectPropsToCamelCase, -} from '~/lib/utils/common_utils'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { GROUP_PAGE_TYPE } from '../constants'; export default { [types.SET_INITIAL_STATE](state, config) { const { comingSoonJson, ...rest } = config; - const comingSoonObj = JSON.parse(comingSoonJson); - state.config = { ...rest, - comingSoon: comingSoonObj && convertObjectPropsToCamelCase(comingSoonObj), isGroupPage: config.pageType === GROUP_PAGE_TYPE, }; }, diff --git a/app/assets/javascripts/packages/list/utils.js b/app/assets/javascripts/packages/list/utils.js index 98d78db8706..6a300d7bfe6 100644 --- a/app/assets/javascripts/packages/list/utils.js +++ b/app/assets/javascripts/packages/list/utils.js @@ -1,7 +1,6 @@ -import { LIST_KEY_PROJECT, TABLE_HEADER_FIELDS } from './constants'; +import { LIST_KEY_PROJECT, SORT_FIELDS } from './constants'; -export default isGroupPage => - TABLE_HEADER_FIELDS.filter(f => f.key !== LIST_KEY_PROJECT || isGroupPage); +export default isGroupPage => SORT_FIELDS.filter(f => f.key !== LIST_KEY_PROJECT || isGroupPage); /** * A small util function that works out if the delete action has deleted the diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue index f93bc51d185..d55ca80a7fc 100644 --- a/app/assets/javascripts/packages/shared/components/package_list_row.vue +++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlIcon, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui'; import PackageTags from './package_tags.vue'; +import PackagePath from './package_path.vue'; import PublishMethod from './publish_method.vue'; import { getPackageTypeLabel } from '../utils'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -15,6 +16,7 @@ export default { GlSprintf, GlTruncate, PackageTags, + PackagePath, PublishMethod, ListItem, }, @@ -92,22 +94,12 @@ export default { </gl-sprintf> </div> - <div v-if="hasProjectLink" class="gl-display-flex gl-align-items-center"> - <gl-icon name="review-list" class="gl-ml-3 gl-mr-2 gl-min-w-0" /> - - <gl-link - class="gl-text-body gl-min-w-0" - data-testid="packages-row-project" - :href="`/${packageEntity.project_path}`" - > - <gl-truncate :text="packageEntity.projectPathName" /> - </gl-link> - </div> - <div v-if="showPackageType" class="d-flex align-items-center" data-testid="package-type"> <gl-icon name="package" class="gl-ml-3 gl-mr-2" /> <span>{{ packageType }}</span> </div> + + <package-path v-if="hasProjectLink" :path="packageEntity.project_path" /> </div> </template> diff --git a/app/assets/javascripts/packages/shared/components/package_path.vue b/app/assets/javascripts/packages/shared/components/package_path.vue new file mode 100644 index 00000000000..9afe06ab497 --- /dev/null +++ b/app/assets/javascripts/packages/shared/components/package_path.vue @@ -0,0 +1,71 @@ +<script> +import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { joinPaths } from '~/lib/utils/url_utility'; + +export default { + name: 'PackagePath', + components: { + GlIcon, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + path: { + type: String, + required: true, + }, + }, + computed: { + pathPieces() { + return this.path.split('/'); + }, + root() { + // we skip the first part of the path since is the 'base' group + return this.pathPieces[1]; + }, + rootLink() { + return joinPaths(this.pathPieces[0], this.root); + }, + leaf() { + return this.pathPieces[this.pathPieces.length - 1]; + }, + deeplyNested() { + return this.pathPieces.length > 3; + }, + hasGroup() { + return this.root !== this.leaf; + }, + }, +}; +</script> + +<template> + <div data-qa-selector="package-path" class="gl-display-flex gl-align-items-center"> + <gl-icon data-testid="base-icon" name="project" class="gl-mx-3 gl-min-w-0" /> + + <gl-link data-testid="root-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${rootLink}`"> + {{ root }} + </gl-link> + + <template v-if="hasGroup"> + <gl-icon data-testid="root-chevron" name="chevron-right" class="gl-mx-2 gl-min-w-0" /> + + <template v-if="deeplyNested"> + <span + v-gl-tooltip="{ title: path }" + data-testid="ellipsis-icon" + class="gl-inset-border-1-gray-200 gl-rounded-base gl-px-2 gl-min-w-0" + > + <gl-icon name="ellipsis_h" /> + </span> + <gl-icon data-testid="ellipsis-chevron" name="chevron-right" class="gl-mx-2 gl-min-w-0" /> + </template> + + <gl-link data-testid="leaf-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${path}`"> + {{ leaf }} + </gl-link> + </template> + </div> +</template> diff --git a/app/assets/javascripts/packages/shared/components/publish_method.vue b/app/assets/javascripts/packages/shared/components/publish_method.vue index d17e23c4032..8a66a33f2ab 100644 --- a/app/assets/javascripts/packages/shared/components/publish_method.vue +++ b/app/assets/javascripts/packages/shared/components/publish_method.vue @@ -49,7 +49,8 @@ export default { <clipboard-button :text="packageEntity.pipeline.sha" :title="__('Copy commit SHA')" - css-class="gl-border-0 gl-py-0 gl-px-2" + category="tertiary" + size="small" /> </template> diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js index e5131db59bf..c481abd8658 100644 --- a/app/assets/javascripts/packages/shared/constants.js +++ b/app/assets/javascripts/packages/shared/constants.js @@ -14,9 +14,6 @@ export const TrackingActions = { REQUEST_DELETE_PACKAGE: 'request_delete_package', CANCEL_DELETE_PACKAGE: 'cancel_delete_package', PULL_PACKAGE: 'pull_package', - COMING_SOON_REQUESTED: 'activate_coming_soon_requested', - COMING_SOON_LIST: 'click_coming_soon_issue_link', - COMING_SOON_HELP: 'click_coming_soon_documentation_link', }; export const TrackingCategories = { diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js index a0c7389651d..b0807558266 100644 --- a/app/assets/javascripts/packages/shared/utils.js +++ b/app/assets/javascripts/packages/shared/utils.js @@ -18,7 +18,7 @@ export const getPackageTypeLabel = packageType => { case PackageType.NUGET: return s__('PackageType|NuGet'); case PackageType.PYPI: - return s__('PackageType|PyPi'); + return s__('PackageType|PyPI'); case PackageType.COMPOSER: return s__('PackageType|Composer'); diff --git a/app/assets/javascripts/pages/admin/credentials/index.js b/app/assets/javascripts/pages/admin/credentials/index.js new file mode 100644 index 00000000000..868c8e33077 --- /dev/null +++ b/app/assets/javascripts/pages/admin/credentials/index.js @@ -0,0 +1,3 @@ +import initConfirmModal from '~/confirm_modal'; + +initConfirmModal(); diff --git a/app/assets/javascripts/pages/admin/instance_statistics/index.js b/app/assets/javascripts/pages/admin/instance_statistics/index.js new file mode 100644 index 00000000000..d6b0a834ce3 --- /dev/null +++ b/app/assets/javascripts/pages/admin/instance_statistics/index.js @@ -0,0 +1,3 @@ +import initInstanceStatisticsApp from '~/analytics/instance_statistics'; + +document.addEventListener('DOMContentLoaded', () => initInstanceStatisticsApp()); diff --git a/app/assets/javascripts/pages/admin/keys/index.js b/app/assets/javascripts/pages/admin/keys/index.js new file mode 100644 index 00000000000..45b83ffcd67 --- /dev/null +++ b/app/assets/javascripts/pages/admin/keys/index.js @@ -0,0 +1,5 @@ +import initConfirmModal from '~/confirm_modal'; + +document.addEventListener('DOMContentLoaded', () => { + initConfirmModal(); +}); diff --git a/app/assets/javascripts/pages/admin/users/keys/index.js b/app/assets/javascripts/pages/admin/users/keys/index.js new file mode 100644 index 00000000000..45b83ffcd67 --- /dev/null +++ b/app/assets/javascripts/pages/admin/users/keys/index.js @@ -0,0 +1,5 @@ +import initConfirmModal from '~/confirm_modal'; + +document.addEventListener('DOMContentLoaded', () => { + initConfirmModal(); +}); diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js index 10df18c85e7..7adae2cdb05 100644 --- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js +++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js @@ -5,7 +5,7 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { - addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); + addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys, true); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 3fa3a132dfa..dc647f5d3cb 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -4,7 +4,8 @@ import memberExpirationDate from '~/member_expiration_date'; import UsersSelect from '~/users_select'; import groupsSelect from '~/groups_select'; import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; -import initGroupMembersApp from '~/groups/members'; +import { initGroupMembersApp } from '~/groups/members'; +import { memberRequestFormatter, groupLinkRequestFormatter } from '~/groups/members/utils'; function mountRemoveMemberModal() { const el = document.querySelector('.js-remove-member-modal'); @@ -26,10 +27,28 @@ document.addEventListener('DOMContentLoaded', () => { memberExpirationDate('.js-access-expiration-date-groups'); mountRemoveMemberModal(); - initGroupMembersApp(document.querySelector('.js-group-members-list')); - initGroupMembersApp(document.querySelector('.js-group-linked-list')); - initGroupMembersApp(document.querySelector('.js-group-invited-members-list')); - initGroupMembersApp(document.querySelector('.js-group-access-requests-list')); + const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; + + initGroupMembersApp( + document.querySelector('.js-group-members-list'), + SHARED_FIELDS.concat(['source', 'granted']), + memberRequestFormatter, + ); + initGroupMembersApp( + document.querySelector('.js-group-linked-list'), + SHARED_FIELDS.concat('granted'), + groupLinkRequestFormatter, + ); + initGroupMembersApp( + document.querySelector('.js-group-invited-members-list'), + SHARED_FIELDS.concat('invited'), + memberRequestFormatter, + ); + initGroupMembersApp( + document.querySelector('.js-group-access-requests-list'), + SHARED_FIELDS.concat('requested'), + memberRequestFormatter, + ); new Members(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index ae481d16ee9..4d0a03e151a 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -8,18 +8,16 @@ import initManualOrdering from '~/manual_ordering'; const ISSUE_BULK_UPDATE_PREFIX = 'issue_'; -document.addEventListener('DOMContentLoaded', () => { - IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); - issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); +IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); +issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); - initIssuablesList(); +initIssuablesList(); - initFilteredSearch({ - page: FILTERED_SEARCH.ISSUES, - isGroupDecendent: true, - useDefaultState: true, - filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, - }); - projectSelect(); - initManualOrdering(); +initFilteredSearch({ + page: FILTERED_SEARCH.ISSUES, + isGroupDecendent: true, + useDefaultState: true, + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, }); +projectSelect(); +initManualOrdering(); diff --git a/app/assets/javascripts/pages/groups/registry/repositories/index.js b/app/assets/javascripts/pages/groups/registry/repositories/index.js index cdafe838994..6fd32321568 100644 --- a/app/assets/javascripts/pages/groups/registry/repositories/index.js +++ b/app/assets/javascripts/pages/groups/registry/repositories/index.js @@ -1,10 +1,8 @@ import registryExplorer from '~/registry/explorer/index'; -document.addEventListener('DOMContentLoaded', () => { - const explorer = registryExplorer(); +const explorer = registryExplorer(); - if (explorer) { - explorer.attachBreadcrumb(); - explorer.attachMainComponent(); - } -}); +if (explorer) { + explorer.attachBreadcrumb(); + explorer.attachMainComponent(); +} diff --git a/app/assets/javascripts/pages/groups/security/credentials/index.js b/app/assets/javascripts/pages/groups/security/credentials/index.js new file mode 100644 index 00000000000..868c8e33077 --- /dev/null +++ b/app/assets/javascripts/pages/groups/security/credentials/index.js @@ -0,0 +1,3 @@ +import initConfirmModal from '~/confirm_modal'; + +initConfirmModal(); 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 add483843df..67eb09da5e0 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -4,6 +4,7 @@ import initVariableList from '~/ci_variable_list'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import GroupRunnersFilteredSearchTokenKeys from '~/filtered_search/group_runners_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; +import initSharedRunnersForm from '~/group_settings/mount_shared_runners'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels @@ -29,4 +30,6 @@ document.addEventListener('DOMContentLoaded', () => { maskableRegex: variableListEl.dataset.maskableRegex, }); } + + initSharedRunnersForm(); }); diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue index 983062c79f1..93fe38831be 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue @@ -1,16 +1,15 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlSafeHtmlDirective as SafeHtml, GlModal } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as Flash } from '~/flash'; -import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; -import { n__, s__, sprintf } from '~/locale'; +import { __, n__, s__, sprintf } from '~/locale'; import { redirectTo } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; export default { components: { - DeprecatedModal, + GlModal, }, directives: { SafeHtml, @@ -115,20 +114,24 @@ Once deleted, it cannot be undone or recovered.`), }); }, }, + primaryProps: { + text: s__('Milestones|Delete milestone'), + attributes: [{ variant: 'danger' }, { category: 'primary' }], + }, + cancelProps: { + text: __('Cancel'), + }, }; </script> <template> - <deprecated-modal - id="delete-milestone-modal" + <gl-modal + modal-id="delete-milestone-modal" :title="title" - :text="text" - :primary-button-label="s__('Milestones|Delete milestone')" - kind="danger" - @submit="onSubmit" + :action-primary="$options.primaryProps" + :action-cancel="$options.cancelProps" + @primary="onSubmit" > - <template #body="props"> - <p v-safe-html="props.text"></p> - </template> - </deprecated-modal> + <p v-safe-html="text"></p> + </gl-modal> </template> diff --git a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js index 1d559dc6e41..6e68114e04b 100644 --- a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js +++ b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; -import deleteMilestoneModal from './components/delete_milestone_modal.vue'; +import DeleteMilestoneModal from './components/delete_milestone_modal.vue'; import eventHub from './event_hub'; export default () => { @@ -18,6 +18,8 @@ export default () => { button.querySelector('.js-loading-icon').classList.add('hidden'); }; + const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button'); + const onRequestStarted = milestoneUrl => { const button = document.querySelector( `.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`, @@ -27,35 +29,8 @@ export default () => { eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished); }; - const onDeleteButtonClick = event => { - const button = event.currentTarget; - const modalProps = { - milestoneId: parseInt(button.dataset.milestoneId, 10), - milestoneTitle: button.dataset.milestoneTitle, - milestoneUrl: button.dataset.milestoneUrl, - issueCount: parseInt(button.dataset.milestoneIssueCount, 10), - mergeRequestCount: parseInt(button.dataset.milestoneMergeRequestCount, 10), - }; - eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted); - eventHub.$emit('deleteMilestoneModal.props', modalProps); - }; - - const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button'); - deleteMilestoneButtons.forEach(button => { - button.addEventListener('click', onDeleteButtonClick); - }); - - eventHub.$once('deleteMilestoneModal.mounted', () => { - deleteMilestoneButtons.forEach(button => { - button.removeAttribute('disabled'); - }); - }); - return new Vue({ - el: '#delete-milestone-modal', - components: { - deleteMilestoneModal, - }, + el: '#js-delete-milestone-modal', data() { return { modalProps: { @@ -69,10 +44,21 @@ export default () => { }, mounted() { eventHub.$on('deleteMilestoneModal.props', this.setModalProps); - eventHub.$emit('deleteMilestoneModal.mounted'); - }, - beforeDestroy() { - eventHub.$off('deleteMilestoneModal.props', this.setModalProps); + deleteMilestoneButtons.forEach(button => { + button.removeAttribute('disabled'); + button.addEventListener('click', () => { + this.$root.$emit('bv::show::modal', 'delete-milestone-modal'); + eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted); + + this.setModalProps({ + milestoneId: parseInt(button.dataset.milestoneId, 10), + milestoneTitle: button.dataset.milestoneTitle, + milestoneUrl: button.dataset.milestoneUrl, + issueCount: parseInt(button.dataset.milestoneIssueCount, 10), + mergeRequestCount: parseInt(button.dataset.milestoneMergeRequestCount, 10), + }); + }); + }); }, methods: { setModalProps(modalProps) { @@ -80,7 +66,7 @@ export default () => { }, }, render(createElement) { - return createElement(deleteMilestoneModal, { + return createElement(DeleteMilestoneModal, { props: this.modalProps, }); }, diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js index d3dcd21f456..4214d5bffb2 100644 --- a/app/assets/javascripts/pages/profiles/keys/index.js +++ b/app/assets/javascripts/pages/profiles/keys/index.js @@ -1,6 +1,9 @@ +import initConfirmModal from '~/confirm_modal'; import AddSshKeyValidation from '~/profile/add_ssh_key_validation'; document.addEventListener('DOMContentLoaded', () => { + initConfirmModal(); + const input = document.querySelector('.js-add-ssh-key-validation-input'); if (!input) return; diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 46e59cd6572..f2e8cb38ef5 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -3,34 +3,33 @@ import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_sta import BlobViewer from '~/blob/viewer/index'; import initBlob from '~/pages/projects/init_blob'; import GpgBadges from '~/gpg_badges'; +import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import '~/sourcegraph/load'; import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { isExperimentEnabled } from '~/lib/utils/experimentation'; const createGitlabCiYmlVisualization = (containerId = '#js-blob-toggle-graph-preview') => { const el = document.querySelector(containerId); - const { filename, blobData } = el?.dataset; + const { isCiConfigFile, blobData } = el?.dataset; - const nameRegexp = /\.gitlab-ci.yml/; - - if (!el || !nameRegexp.test(filename)) { - return; + if (el && parseBoolean(isCiConfigFile)) { + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + GitlabCiYamlVisualization: () => + import('~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'), + }, + render(createElement) { + return createElement('gitlabCiYamlVisualization', { + props: { + blobData, + }, + }); + }, + }); } - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - GitlabCiYamlVisualization: () => - import('~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'), - }, - render(createElement) { - return createElement('gitlabCiYamlVisualization', { - props: { - blobData, - }, - }); - }, - }); }; document.addEventListener('DOMContentLoaded', () => { @@ -57,11 +56,13 @@ document.addEventListener('DOMContentLoaded', () => { }); } + initWebIdeLink({ el: document.getElementById('js-blob-web-ide-link') }); + GpgBadges.fetch(); const codeNavEl = document.getElementById('js-code-navigation'); - if (gon.features?.codeNavigation && codeNavEl) { + if (codeNavEl) { const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset; // eslint-disable-next-line promise/catch-or-return @@ -73,7 +74,7 @@ document.addEventListener('DOMContentLoaded', () => { ); } - if (gon.features?.suggestPipeline) { + if (isExperimentEnabled('suggestPipeline')) { const successPipelineEl = document.querySelector('.js-success-pipeline-modal'); if (successPipelineEl) { diff --git a/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js b/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js index d270bee25c7..df635522e94 100644 --- a/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js +++ b/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js @@ -1,13 +1,11 @@ -import createFlash from '~/flash'; -import { BLOB_EDITOR_ERROR } from '~/blob_edit/constants'; +import EditorLite from '~/editor/editor_lite'; export default class CILintEditor { constructor() { - const monacoEnabled = window?.gon?.features?.monacoCi; this.clearYml = document.querySelector('.clear-yml'); this.clearYml.addEventListener('click', this.clear.bind(this)); - return monacoEnabled ? this.initEditorLite() : this.initAce(); + return this.initEditorLite(); } clear() { @@ -15,34 +13,20 @@ export default class CILintEditor { } initEditorLite() { - import(/* webpackChunkName: 'monaco_editor_lite' */ '~/editor/editor_lite') - .then(({ default: EditorLite }) => { - const editorEl = document.getElementById('editor'); - const fileContentEl = document.getElementById('content'); - const form = document.querySelector('.js-ci-lint-form'); + const editorEl = document.getElementById('editor'); + const fileContentEl = document.getElementById('content'); + const form = document.querySelector('.js-ci-lint-form'); - const rootEditor = new EditorLite(); + const rootEditor = new EditorLite(); - this.editor = rootEditor.createInstance({ - el: editorEl, - blobPath: '.gitlab-ci.yml', - blobContent: editorEl.innerText, - }); - - form.addEventListener('submit', () => { - fileContentEl.value = this.editor.getValue(); - }); - }) - .catch(() => createFlash({ message: BLOB_EDITOR_ERROR })); - } - - initAce() { - this.editor = window.ace.edit('ci-editor'); - this.textarea = document.getElementById('content'); + this.editor = rootEditor.createInstance({ + el: editorEl, + blobPath: '.gitlab-ci.yml', + blobContent: editorEl.innerText, + }); - this.editor.getSession().setMode('ace/mode/yaml'); - this.editor.on('input', () => { - this.textarea.value = this.editor.getSession().getValue(); + form.addEventListener('submit', () => { + fileContentEl.value = this.editor.getValue(); }); } } diff --git a/app/assets/javascripts/pages/projects/ci/lints/new/index.js b/app/assets/javascripts/pages/projects/ci/lints/new/index.js index 02bfee9810f..957801320c9 100644 --- a/app/assets/javascripts/pages/projects/ci/lints/new/index.js +++ b/app/assets/javascripts/pages/projects/ci/lints/new/index.js @@ -1,11 +1,17 @@ -import CILintEditor from '../ci_lint_editor'; -import initCILint from '~/ci_lint/index'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +const ERROR = __('An error occurred while rendering the linter'); document.addEventListener('DOMContentLoaded', () => { if (gon?.features?.ciLintVue) { - initCILint(); + import(/* webpackChunkName: 'ciLintIndex' */ '~/ci_lint/index') + .then(module => module.default()) + .catch(() => createFlash(ERROR)); } else { - // eslint-disable-next-line no-new - new CILintEditor(); + import(/* webpackChunkName: 'ciLintEditor' */ '../ci_lint_editor') + // eslint-disable-next-line new-cap + .then(module => new module.default()) + .catch(() => createFlash(ERROR)); } }); diff --git a/app/assets/javascripts/pages/projects/ci/lints/show/index.js b/app/assets/javascripts/pages/projects/ci/lints/show/index.js index 8e8a843da0b..957801320c9 100644 --- a/app/assets/javascripts/pages/projects/ci/lints/show/index.js +++ b/app/assets/javascripts/pages/projects/ci/lints/show/index.js @@ -1,3 +1,17 @@ -import CILintEditor from '../ci_lint_editor'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; -document.addEventListener('DOMContentLoaded', () => new CILintEditor()); +const ERROR = __('An error occurred while rendering the linter'); + +document.addEventListener('DOMContentLoaded', () => { + if (gon?.features?.ciLintVue) { + import(/* webpackChunkName: 'ciLintIndex' */ '~/ci_lint/index') + .then(module => module.default()) + .catch(() => createFlash(ERROR)); + } else { + import(/* webpackChunkName: 'ciLintEditor' */ '../ci_lint_editor') + // eslint-disable-next-line new-cap + .then(module => new module.default()) + .catch(() => createFlash(ERROR)); + } +}); diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js index 744be65bfbe..1124eb5d939 100644 --- a/app/assets/javascripts/pages/projects/clusters/index/index.js +++ b/app/assets/javascripts/pages/projects/clusters/index/index.js @@ -1,5 +1,5 @@ +import initClustersListApp from 'ee_else_ce/clusters_list'; import PersistentUserCallout from '~/persistent_user_callout'; -import initClustersListApp from '~/clusters_list'; document.addEventListener('DOMContentLoaded', () => { const callout = document.querySelector('.gcp-signup-offer'); diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js index 1415a6f60c8..26dea17ca8a 100644 --- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js +++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js @@ -1,14 +1,8 @@ -import $ from 'jquery'; -import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; +import { initCommitBoxInfo } from '~/projects/commit_box/info'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; -import { fetchCommitMergeRequests } from '~/commit_merge_requests'; document.addEventListener('DOMContentLoaded', () => { - new MiniPipelineGraph({ - container: '.js-commit-pipeline-graph', - }).bindEvents(); - // eslint-disable-next-line no-jquery/no-load - $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); - fetchCommitMergeRequests(); + initCommitBoxInfo(); + initPipelines(); }); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index d5fb2a8be3c..32fb35f97e3 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -4,10 +4,8 @@ import $ from 'jquery'; import Diff from '~/diff'; import ZenMode from '~/zen_mode'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; import initNotes from '~/init_notes'; import initChangesDropdown from '~/init_changes_dropdown'; -import { fetchCommitMergeRequests } from '~/commit_merge_requests'; import '~/sourcegraph/load'; import { handleLocationHash } from '~/lib/utils/common_utils'; import axios from '~/lib/utils/axios_utils'; @@ -15,6 +13,7 @@ import syntaxHighlight from '~/syntax_highlight'; import flash from '~/flash'; import { __ } from '~/locale'; import loadAwardsHandler from '~/awards_handler'; +import { initCommitBoxInfo } from '~/projects/commit_box/info'; document.addEventListener('DOMContentLoaded', () => { const hasPerfBar = document.querySelector('.with-performance-bar'); @@ -22,13 +21,10 @@ document.addEventListener('DOMContentLoaded', () => { initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight); new ZenMode(); new ShortcutsNavigation(); - new MiniPipelineGraph({ - container: '.js-commit-pipeline-graph', - }).bindEvents(); + + initCommitBoxInfo(); + initNotes(); - // eslint-disable-next-line no-jquery/no-load - $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); - fetchCommitMergeRequests(); const filesContainer = $('.js-diffs-batch'); diff --git a/app/assets/javascripts/pages/projects/environments/index/index.js b/app/assets/javascripts/pages/projects/environments/index/index.js index ace8af00ece..4d5106f6d5f 100644 --- a/app/assets/javascripts/pages/projects/environments/index/index.js +++ b/app/assets/javascripts/pages/projects/environments/index/index.js @@ -1,3 +1,3 @@ -import initEnviroments from '~/environments/'; +import initEnvironments from '~/environments/'; -document.addEventListener('DOMContentLoaded', initEnviroments); +document.addEventListener('DOMContentLoaded', initEnvironments); diff --git a/app/assets/javascripts/pages/projects/feature_flags/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags/edit/index.js new file mode 100644 index 00000000000..36b1d800103 --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags/edit/index.js @@ -0,0 +1,3 @@ +import initEditFeatureFlags from '~/feature_flags/edit'; + +initEditFeatureFlags(); diff --git a/app/assets/javascripts/pages/projects/feature_flags/index/index.js b/app/assets/javascripts/pages/projects/feature_flags/index/index.js new file mode 100644 index 00000000000..c11a5c929ee --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags/index/index.js @@ -0,0 +1,3 @@ +import initFeatureFlags from '~/feature_flags'; + +initFeatureFlags(); diff --git a/app/assets/javascripts/pages/projects/feature_flags/new/index.js b/app/assets/javascripts/pages/projects/feature_flags/new/index.js new file mode 100644 index 00000000000..d598f6b31dd --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags/new/index.js @@ -0,0 +1,3 @@ +import initNewFeatureFlags from '~/feature_flags/new'; + +initNewFeatureFlags(); diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js new file mode 100644 index 00000000000..bbe84322462 --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import EditUserList from '~/user_lists/components/edit_user_list.vue'; +import createStore from '~/user_lists/store/edit'; + +Vue.use(Vuex); + +document.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('js-edit-user-list'); + const { userListsDocsPath } = el.dataset; + return new Vue({ + el, + store: createStore(el.dataset), + provide: { userListsDocsPath }, + render(h) { + return h(EditUserList, {}); + }, + }); +}); diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js new file mode 100644 index 00000000000..679f0af8efc --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import NewUserList from '~/user_lists/components/new_user_list.vue'; +import createStore from '~/user_lists/store/new'; + +Vue.use(Vuex); + +document.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('js-new-user-list'); + const { userListsDocsPath, featureFlagsPath } = el.dataset; + return new Vue({ + el, + store: createStore(el.dataset), + provide: { + userListsDocsPath, + featureFlagsPath, + }, + render(h) { + return h(NewUserList); + }, + }); +}); diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js new file mode 100644 index 00000000000..bccd9dce2ec --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import UserList from '~/user_lists/components/user_list.vue'; +import createStore from '~/user_lists/store/show'; + +Vue.use(Vuex); + +document.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('js-edit-user-list'); + return new Vue({ + el, + store: createStore(el.dataset), + render(h) { + const { emptyStatePath } = el.dataset; + return h(UserList, { props: { emptyStatePath } }); + }, + }); +}); diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index 384216f29eb..74abd1f67a5 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -1,6 +1,6 @@ -import Vue from 'vue'; import { GlColumnChart } from '@gitlab/ui/dist/charts'; -import { waitForCSSLoaded } from '../../../../helpers/startup_css_helper'; +import Vue from 'vue'; +import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; import { __ } from '~/locale'; import CodeCoverage from '../components/code_coverage.vue'; import SeriesDataMixin from './series_data_mixin'; 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 5d59880d497..a9079f91f50 100644 --- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue +++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue @@ -1,11 +1,5 @@ <script> -import { - GlAlert, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlIcon, - GlSprintf, -} from '@gitlab/ui'; +import { GlAlert, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; import { get } from 'lodash'; @@ -17,9 +11,8 @@ export default { components: { GlAlert, GlAreaChart, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlIcon, + GlDropdown, + GlDropdownItem, GlSprintf, }, props: { @@ -140,25 +133,18 @@ export default { {{ __('It seems that there is currently no available data for code coverage') }} </span> </gl-alert> - <gl-deprecated-dropdown v-if="canShowData" :text="selectedDailyCoverageName"> - <gl-deprecated-dropdown-item + <gl-dropdown v-if="canShowData" :text="selectedDailyCoverageName"> + <gl-dropdown-item v-for="({ group_name }, index) in dailyCoverageData" :key="index" :value="group_name" + :is-check-item="true" + :is-checked="index === selectedCoverageIndex" @click="setSelectedCoverage(index)" > - <div class="gl-display-flex"> - <gl-icon - v-if="index === selectedCoverageIndex" - name="mobile-issue-close" - class="gl-absolute" - /> - <span class="gl-display-flex align-items-center ml-4"> - {{ group_name }} - </span> - </div> - </gl-deprecated-dropdown-item> - </gl-deprecated-dropdown> + {{ group_name }} + </gl-dropdown-item> + </gl-dropdown> </div> <gl-area-chart v-if="!isLoading" diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js new file mode 100644 index 00000000000..3324cfc0335 --- /dev/null +++ b/app/assets/javascripts/pages/projects/incidents/show/index.js @@ -0,0 +1,11 @@ +import initSidebarBundle from '~/sidebar/sidebar_bundle'; +import initRelatedIssues from '~/related_issues'; +import initShow from '../../issues/show'; + +document.addEventListener('DOMContentLoaded', () => { + initShow(); + if (!gon.features?.vueIssuableSidebar) { + initSidebarBundle(); + } + initRelatedIssues(); +}); diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 8e0af018b61..3e9962a4e72 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,7 +1,5 @@ import Project from './project'; import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; -document.addEventListener('DOMContentLoaded', () => { - new Project(); // eslint-disable-line no-new - new ShortcutsNavigation(); // eslint-disable-line no-new -}); +new Project(); // eslint-disable-line no-new +new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index e1add4a2af3..f3ccedc47c8 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -11,20 +11,18 @@ import initIssuablesList from '~/issues_list'; import initManualOrdering from '~/manual_ordering'; import { showLearnGitLabIssuesPopover } from '~/onboarding_issues'; -document.addEventListener('DOMContentLoaded', () => { - IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); +IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); - initFilteredSearch({ - page: FILTERED_SEARCH.ISSUES, - filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, - useDefaultState: true, - }); +initFilteredSearch({ + page: FILTERED_SEARCH.ISSUES, + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + useDefaultState: true, +}); - new IssuableIndex(ISSUABLE_INDEX.ISSUE); - new ShortcutsNavigation(); - new UsersSelect(); +new IssuableIndex(ISSUABLE_INDEX.ISSUE); +new ShortcutsNavigation(); +new UsersSelect(); - initManualOrdering(); - initIssuablesList(); - showLearnGitLabIssuesPopover(); -}); +initManualOrdering(); +initIssuablesList(); +showLearnGitLabIssuesPopover(); diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js index aecc6484b26..48afd2142ee 100644 --- a/app/assets/javascripts/pages/projects/issues/new/index.js +++ b/app/assets/javascripts/pages/projects/issues/new/index.js @@ -1,3 +1,3 @@ import initForm from 'ee_else_ce/pages/projects/issues/form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js index e0c1332796f..231ee6732e9 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -1,17 +1,15 @@ import FilteredSearchServiceDesk from './filtered_search'; import initIssuablesList from '~/issues_list'; -document.addEventListener('DOMContentLoaded', () => { - const supportBotData = JSON.parse( - document.querySelector('.js-service-desk-issues').dataset.supportBot, - ); +const supportBotData = JSON.parse( + document.querySelector('.js-service-desk-issues').dataset.supportBot, +); - if (document.querySelector('.filtered-search')) { - const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); - filteredSearchManager.setup(); - } +if (document.querySelector('.filtered-search')) { + const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); + filteredSearchManager.setup(); +} - if (gon.features?.vueIssuablesList) { - initIssuablesList(); - } -}); +if (gon.features?.vueIssuablesList) { + initIssuablesList(); +} diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 98ae4e26257..a58b5d3f37c 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -10,16 +10,24 @@ import initIncidentApp from '~/issue_show/incident'; import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning'; import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; import initRelatedMergeRequestsApp from '~/related_merge_requests'; -import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; import { parseIssuableData } from '~/issue_show/utils/parse_data'; +import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger'; +import initInviteMemberModal from '~/invite_member/init_invite_member_modal'; + +import { IssuableType } from '~/issuable_show/constants'; export default function() { const { issueType, ...issuableData } = parseIssuableData(); - if (issueType === 'incident') { - initIncidentApp(issuableData); - } else { - initIssueApp(issuableData); + switch (issueType) { + case IssuableType.Incident: + initIncidentApp(issuableData); + break; + case IssuableType.Issue: + initIssueApp(issuableData); + break; + default: + break; } initIssuableHeaderWarning(store); @@ -30,14 +38,14 @@ export default function() { .then(module => module.default()) .catch(() => {}); - new Issue(); // eslint-disable-line no-new - new ShortcutsIssuable(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new - if (gon.features && gon.features.vueIssuableSidebar) { - initVueIssuableSidebarApp(); - } else { + + if (issueType !== IssuableType.TestCase) { + new Issue(); // eslint-disable-line no-new + new ShortcutsIssuable(); // eslint-disable-line no-new initIssuableSidebar(); + loadAwardsHandler(); + initInviteMemberModal(); + initInviteMemberTrigger(); } - - loadAwardsHandler(); } diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js index aef4feef42c..630add51a97 100644 --- a/app/assets/javascripts/pages/projects/issues/show/index.js +++ b/app/assets/javascripts/pages/projects/issues/show/index.js @@ -2,10 +2,8 @@ import initSidebarBundle from '~/sidebar/sidebar_bundle'; import initRelatedIssues from '~/related_issues'; import initShow from '../show'; -document.addEventListener('DOMContentLoaded', () => { - initShow(); - if (gon.features && !gon.features.vueIssuableSidebar) { - initSidebarBundle(); - } - initRelatedIssues(); -}); +initShow(); +if (gon.features && !gon.features.vueIssuableSidebar) { + initSidebarBundle(); +} +initRelatedIssues(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index ce0b5c80927..94a12cc2706 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -7,16 +7,15 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; -document.addEventListener('DOMContentLoaded', () => { - addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); +new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new - initFilteredSearch({ - page: FILTERED_SEARCH.MERGE_REQUESTS, - filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, - useDefaultState: true, - }); +addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); - new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new - new ShortcutsNavigation(); // eslint-disable-line no-new - new UsersSelect(); // eslint-disable-line no-new +initFilteredSearch({ + page: FILTERED_SEARCH.MERGE_REQUESTS, + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + useDefaultState: true, }); + +new UsersSelect(); // eslint-disable-line no-new +new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index 11af50169f5..868e001b182 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -4,21 +4,20 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import { handleLocationHash } from '~/lib/utils/common_utils'; import howToMerge from '~/how_to_merge'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; -import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; import initSourcegraph from '~/sourcegraph'; import loadAwardsHandler from '~/awards_handler'; +import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger'; +import initInviteMemberModal from '~/invite_member/init_invite_member_modal'; export default function() { new ZenMode(); // eslint-disable-line no-new - if (gon.features && gon.features.vueIssuableSidebar) { - initVueIssuableSidebarApp(); - } else { - initIssuableSidebar(); - } + initIssuableSidebar(); initPipelines(); new ShortcutsIssuable(true); // eslint-disable-line no-new handleLocationHash(); howToMerge(); initSourcegraph(); loadAwardsHandler(); + initInviteMemberModal(); + initInviteMemberTrigger(); } diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index 29ebf656fe1..602d749ee07 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -5,12 +5,10 @@ import initShow from '../init_merge_request_show'; import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning'; import store from '~/mr_notes/stores'; -document.addEventListener('DOMContentLoaded', () => { - initShow(); - if (gon.features && !gon.features.vueIssuableSidebar) { - initSidebarBundle(); - } - initMrNotes(); - initReviewBar(); - initIssuableHeaderWarning(store); -}); +initShow(); +if (gon.features && !gon.features.vueIssuableSidebar) { + initSidebarBundle(); +} +initMrNotes(); +initReviewBar(); +initIssuableHeaderWarning(store); diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index 637ed28a758..477a1ab887b 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -3,13 +3,14 @@ import initProjectNew from '../../../projects/project_new'; import { __ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import Tracking from '~/tracking'; +import { isExperimentEnabled } from '~/lib/utils/experimentation'; document.addEventListener('DOMContentLoaded', () => { initProjectVisibilitySelector(); initProjectNew.bindEvents(); const { category, property } = gon.tracking_data ?? { category: 'projects:new' }; - const hasNewCreateProjectUi = 'newCreateProjectUi' in gon?.features; + const hasNewCreateProjectUi = isExperimentEnabled('newCreateProjectUi'); if (!hasNewCreateProjectUi) { // Setting additional tracking for HAML template diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js index 4836900aa28..c94782fdf1b 100644 --- a/app/assets/javascripts/pages/projects/packages/packages/index/index.js +++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js @@ -1,7 +1,3 @@ import initPackageList from '~/packages/list/packages_list_app_bundle'; -document.addEventListener('DOMContentLoaded', () => { - if (document.getElementById('js-vue-packages-list')) { - initPackageList(); - } -}); +initPackageList(); diff --git a/app/assets/javascripts/pages/projects/packages/packages/show/index.js b/app/assets/javascripts/pages/projects/packages/packages/show/index.js index 1fde4ddfc1d..1afb900ed88 100644 --- a/app/assets/javascripts/pages/projects/packages/packages/show/index.js +++ b/app/assets/javascripts/pages/projects/packages/packages/show/index.js @@ -1,3 +1,3 @@ import initPackageDetail from '~/packages/details/'; -document.addEventListener('DOMContentLoaded', initPackageDetail); +initPackageDetail(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue index 7a3923dfefd..a138a3a3425 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue @@ -1,11 +1,8 @@ <script> -/* eslint-disable vue/no-v-html */ import Vue from 'vue'; import Cookies from 'js-cookie'; import { GlIcon } from '@gitlab/ui'; import Translate from '../../../../../vue_shared/translate'; -// Full path is needed for Jest to be able to correctly mock this file -import illustrationSvg from '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg'; import { parseBoolean } from '~/lib/utils/common_utils'; Vue.use(Translate); @@ -20,12 +17,10 @@ export default { data() { return { docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl, + imageUrl: document.getElementById('pipeline-schedules-callout').dataset.imageUrl, calloutDismissed: parseBoolean(Cookies.get(cookieKey)), }; }, - created() { - this.illustrationSvg = illustrationSvg; - }, methods: { dismissCallout() { this.calloutDismissed = true; @@ -40,7 +35,9 @@ export default { <button id="dismiss-callout-btn" class="btn btn-default close" @click="dismissCallout"> <gl-icon name="close" aria-hidden="true" /> </button> - <div class="svg-container" v-html="illustrationSvg"></div> + <div class="svg-container"> + <img :src="imageUrl" /> + </div> <div class="user-callout-copy"> <h4>{{ __('Scheduling Pipelines') }}</h4> <p> diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg deleted file mode 100644 index 26d1ff97b3e..00000000000 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="140" height="102" viewBox="0 0 140 102" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="12.033" height="40.197" rx="3"/><rect id="b" width="12.033" height="40.197" rx="3"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(-.446)"><path d="M91.747 35.675v-6.039a2.996 2.996 0 0 0-2.999-3.005H54.635a2.997 2.997 0 0 0-2.999 3.005v6.039H40.092a3.007 3.007 0 0 0-2.996 3.005v34.187a2.995 2.995 0 0 0 2.996 3.005h11.544V79.9a2.996 2.996 0 0 0 2.999 3.005h34.113a2.997 2.997 0 0 0 2.999-3.005v-4.03h11.544a3.007 3.007 0 0 0 2.996-3.004V38.68a2.995 2.995 0 0 0-2.996-3.005H91.747z" stroke="#B5A7DD" stroke-width="2"/><rect stroke="#E5E5E5" stroke-width="2" fill="#FFF" x="21.556" y="38.69" width="98.27" height="34.167" rx="3"/><path d="M121.325 38.19c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zM121.325 71.854a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038z" fill="#E5E5E5"/><g transform="translate(110.3 35.675)"><use fill="#FFF" xlink:href="#a"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="9.547" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.099" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="30.65" rx="1.504" ry="1.507"/></g><path d="M6.008 38.19c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zM6.008 71.854a1.004 1.004 0 0 1 0 2.006H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039z" fill="#E5E5E5"/><g transform="translate(19.05 35.675)"><use fill="#FFF" xlink:href="#b"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="10.049" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.601" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="31.153" rx="1.504" ry="1.507"/></g><g transform="translate(47.096)"><g transform="translate(7.05)"><ellipse fill="#FC8A51" cx="17.548" cy="5.025" rx="4.512" ry="4.522"/><rect stroke="#B5A7DD" stroke-width="2" fill="#FFF" x="13.036" y="4.02" width="9.025" height="20.099" rx="1.5"/><rect stroke="#FDE5D8" stroke-width="2" fill="#FFF" y="4.02" width="35.096" height="4.02" rx="2.01"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.512" y="18.089" width="26.072" height="17.084" rx="1.5"/></g><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(-45 43.117 35.117)" x="38.168" y="31.416" width="9.899" height="7.403" rx="3.702"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="25" ry="25"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="21" ry="21"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="43.05" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.305" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 74.422)" x="23.677" y="73.653" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 35.51)" x="23.844" y="34.742" width="2.616" height="1.538" rx=".769"/><path d="M13.362 42.502c-.124-.543.198-.854.74-.69l2.321.704c.533.161.643.592.235.972l-.22.206 7.06 7.572a1.002 1.002 0 1 1-1.467 1.368l-7.06-7.573-.118.11c-.402.375-.826.248-.952-.304l-.54-2.365zM21.606 67.576c-.408.38-.84.255-.968-.295l-.551-2.363c-.127-.542.191-.852.725-.69l.288.089 3.027-9.901a1.002 1.002 0 1 1 1.918.586l-3.027 9.901.154.047c.525.16.627.592.213.977l-1.779 1.65z" fill="#FC8A51"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25.099" cy="54.768" rx="2.507" ry="2.512"/></g></g><path d="M52.697 96.966a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zM86.29 96.966c0-.55.444-.996 1.002-.996.554 0 1.003.454 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044c0-.55.444-.996 1.002-.996.554 0 1.003.453 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038z" fill="#E5E5E5"/></g></svg>
\ No newline at end of file diff --git a/app/assets/javascripts/pages/projects/registry/repositories/index.js b/app/assets/javascripts/pages/projects/registry/repositories/index.js index cdafe838994..6fd32321568 100644 --- a/app/assets/javascripts/pages/projects/registry/repositories/index.js +++ b/app/assets/javascripts/pages/projects/registry/repositories/index.js @@ -1,10 +1,8 @@ import registryExplorer from '~/registry/explorer/index'; -document.addEventListener('DOMContentLoaded', () => { - const explorer = registryExplorer(); +const explorer = registryExplorer(); - if (explorer) { - explorer.attachBreadcrumb(); - explorer.attachMainComponent(); - } -}); +if (explorer) { + explorer.attachBreadcrumb(); + explorer.attachMainComponent(); +} diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index ab2a7c099c4..40816420eef 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -4,6 +4,7 @@ import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; import registrySettingsApp from '~/registry/settings/registry_settings_bundle'; import initVariableList from '~/ci_variable_list'; import initDeployFreeze from '~/deploy_freeze'; +import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels @@ -42,4 +43,6 @@ document.addEventListener('DOMContentLoaded', () => { registrySettingsApp(); initDeployFreeze(); + + initSettingsPipelinesTriggers(); }); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue index 533065b2d4d..0f145dbc170 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue @@ -1,17 +1,17 @@ <script> +import { GlIcon } from '@gitlab/ui'; import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; import { featureAccessLevelNone } from '../constants'; export default { components: { + GlIcon, projectFeatureToggle, }, - model: { prop: 'value', event: 'change', }, - props: { name: { type: String, @@ -34,7 +34,6 @@ export default { default: false, }, }, - computed: { featureEnabled() { return this.value !== 0; @@ -51,7 +50,6 @@ export default { return this.disabledInput || !this.featureEnabled || this.displayOptions.length < 2; }, }, - methods: { toggleFeature(featureEnabled) { if (featureEnabled === false || this.options.length < 1) { @@ -70,14 +68,18 @@ export default { </script> <template> - <div :data-for="name" class="project-feature-controls"> + <div + :data-for="name" + class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0" + > <input v-if="name" :name="name" :value="value" type="hidden" /> <project-feature-toggle + class="gl-flex-grow-0 gl-mr-3" :value="featureEnabled" :disabled-input="disabledInput" @change="toggleFeature" /> - <div class="select-wrapper"> + <div class="select-wrapper gl-flex-fill-1"> <select :disabled="displaySelectInput" class="form-control project-repo-select select-control" @@ -92,7 +94,11 @@ export default { {{ optionName }} </option> </select> - <i aria-hidden="true" class="fa fa-chevron-down"> </i> + <gl-icon + name="chevron-down" + aria-hidden="true" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + /> </div> </div> </template> 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 a95f0af46cd..bcf82e264d1 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 @@ -1,5 +1,5 @@ <script> -import { GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui'; +import { GlIcon, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui'; import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; import { s__ } from '~/locale'; @@ -22,6 +22,7 @@ export default { projectFeatureSetting, projectFeatureToggle, projectSettingRow, + GlIcon, GlSprintf, GlLink, GlFormCheckbox, @@ -292,14 +293,16 @@ export default { <template> <div> - <div class="project-visibility-setting"> + <div + class="project-visibility-setting gl-border-1 gl-border-solid gl-border-gray-100 gl-py-3 gl-px-7 gl-sm-pr-5 gl-sm-pl-5" + > <project-setting-row ref="project-visibility-settings" :help-path="visibilityHelpPath" :label="s__('ProjectSettings|Project visibility')" > - <div class="project-feature-controls"> - <div class="select-wrapper"> + <div class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0"> + <div class="select-wrapper gl-flex-fill-1"> <select v-model="visibilityLevel" :disabled="!canChangeVisibilityLevel" @@ -323,11 +326,16 @@ export default { >{{ s__('ProjectSettings|Public') }}</option > </select> - <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> + <gl-icon + name="chevron-down" + aria-hidden="true" + data-hidden="true" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + /> </div> </div> <span class="form-text text-muted">{{ visibilityLevelDescription }}</span> - <label v-if="visibilityLevel !== visibilityOptions.PRIVATE" class="request-access"> + <label v-if="visibilityLevel !== visibilityOptions.PRIVATE" class="gl-line-height-28"> <input :value="requestAccessEnabled" type="hidden" @@ -338,7 +346,10 @@ export default { </label> </project-setting-row> </div> - <div :class="{ 'highlight-changes': highlightChangesClass }" class="project-feature-settings"> + <div + :class="{ 'highlight-changes': highlightChangesClass }" + class="gl-border-1 gl-border-solid gl-border-t-none gl-border-gray-100 gl-mb-5 gl-py-3 gl-px-7 gl-sm-pr-5 gl-sm-pl-5 gl-bg-gray-10" + > <project-setting-row ref="issues-settings" :label="s__('ProjectSettings|Issues')" @@ -361,7 +372,7 @@ export default { name="project[project_feature_attributes][repository_access_level]" /> </project-setting-row> - <div class="project-feature-setting-group"> + <div class="project-feature-setting-group gl-pl-7 gl-sm-pl-5"> <project-setting-row ref="merge-request-settings" :label="s__('ProjectSettings|Merge requests')" @@ -516,8 +527,8 @@ export default { ) " > - <div class="project-feature-controls"> - <div class="select-wrapper"> + <div class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0"> + <div class="select-wrapper gl-flex-fill-1"> <select v-model="metricsDashboardAccessLevel" :disabled="metricsOptionsDropdownEnabled" @@ -535,7 +546,12 @@ export default { >{{ featureAccessLevelEveryone[1] }}</option > </select> - <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> + <gl-icon + name="chevron-down" + aria-hidden="true" + data-hidden="true" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + /> </div> </div> </project-setting-row> diff --git a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js new file mode 100644 index 00000000000..5f08943d211 --- /dev/null +++ b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { joinPaths, webIDEUrl } from '~/lib/utils/url_utility'; +import WebIdeButton from '~/vue_shared/components/web_ide_link.vue'; + +export default ({ el, router }) => { + if (!el) return; + + const { projectPath, ref, isBlob, webIdeUrl, ...options } = convertObjectPropsToCamelCase( + JSON.parse(el.dataset.options), + ); + + // eslint-disable-next-line no-new + new Vue({ + el, + router, + render(h) { + return h(WebIdeButton, { + props: { + isBlob, + webIdeUrl: isBlob + ? webIdeUrl + : webIDEUrl( + joinPaths('/', projectPath, 'edit', ref, '-', this.$route?.params.path || '', '/'), + ), + ...options, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index fd522b975a6..dd8141d34c7 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -11,34 +11,34 @@ import Star from '../../../star'; import notificationsDropdown from '../../../notifications_dropdown'; import { showLearnGitLabProjectPopover } from '~/onboarding_issues'; -document.addEventListener('DOMContentLoaded', () => { - initReadMore(); - new Star(); // eslint-disable-line no-new - notificationsDropdown(); - new ShortcutsNavigation(); // eslint-disable-line no-new - new NotificationsForm(); // eslint-disable-line no-new - // eslint-disable-next-line no-new - new UserCallout({ - setCalloutPerProject: false, - className: 'js-autodevops-banner', - }); - - // Project show page loads different overview content based on user preferences - const treeSlider = document.getElementById('js-tree-list'); - if (treeSlider) { - initBlob(); - initTree(); - } - - if (document.querySelector('.blob-viewer')) { - new BlobViewer(); // eslint-disable-line no-new - } - - if (document.querySelector('.project-show-activity')) { - new Activities(); // eslint-disable-line no-new - } - - leaveByUrl('project'); - - showLearnGitLabProjectPopover(); +initReadMore(); +new Star(); // eslint-disable-line no-new + +new NotificationsForm(); // eslint-disable-line no-new +// eslint-disable-next-line no-new +new UserCallout({ + setCalloutPerProject: false, + className: 'js-autodevops-banner', }); + +// Project show page loads different overview content based on user preferences +const treeSlider = document.getElementById('js-tree-list'); +if (treeSlider) { + initBlob(); + initTree(); +} + +if (document.querySelector('.blob-viewer')) { + new BlobViewer(); // eslint-disable-line no-new +} + +if (document.querySelector('.project-show-activity')) { + new Activities(); // eslint-disable-line no-new +} + +leaveByUrl('project'); + +showLearnGitLabProjectPopover(); + +notificationsDropdown(); +new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/tags/index/index.js b/app/assets/javascripts/pages/projects/tags/index/index.js new file mode 100644 index 00000000000..ec56fa3e075 --- /dev/null +++ b/app/assets/javascripts/pages/projects/tags/index/index.js @@ -0,0 +1,12 @@ +import { initRemoveTag } from '../remove_tag'; + +document.addEventListener('DOMContentLoaded', () => { + initRemoveTag({ + onDelete: path => { + document + .querySelector(`[data-path="${path}"]`) + .closest('.js-tag-list') + .remove(); + }, + }); +}); diff --git a/app/assets/javascripts/pages/projects/tags/remove_tag.js b/app/assets/javascripts/pages/projects/tags/remove_tag.js new file mode 100644 index 00000000000..7e83dbe0565 --- /dev/null +++ b/app/assets/javascripts/pages/projects/tags/remove_tag.js @@ -0,0 +1,16 @@ +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import initConfirmModal from '~/confirm_modal'; + +export const initRemoveTag = ({ onDelete = () => {} }) => { + return initConfirmModal({ + handleSubmit: (path = '') => + axios + .delete(path) + .then(() => onDelete(path)) + .catch(({ response: { data } }) => { + const { message } = data; + createFlash({ message }); + }), + }); +}; diff --git a/app/assets/javascripts/pages/projects/tags/show/index.js b/app/assets/javascripts/pages/projects/tags/show/index.js new file mode 100644 index 00000000000..651cc05ca4f --- /dev/null +++ b/app/assets/javascripts/pages/projects/tags/show/index.js @@ -0,0 +1,10 @@ +import { redirectTo, getBaseURL, stripFinalUrlSegment } from '~/lib/utils/url_utility'; +import { initRemoveTag } from '../remove_tag'; + +document.addEventListener('DOMContentLoaded', () => { + initRemoveTag({ + onDelete: (path = '') => { + redirectTo(stripFinalUrlSegment([getBaseURL(), path].join(''))); + }, + }); +}); diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js index b19abda2821..4bb461aadad 100644 --- a/app/assets/javascripts/pages/projects/tree/show/index.js +++ b/app/assets/javascripts/pages/projects/tree/show/index.js @@ -4,9 +4,7 @@ import initBlob from '~/blob_edit/blob_bundle'; import ShortcutsNavigation from '../../../../behaviors/shortcuts/shortcuts_navigation'; import NewCommitForm from '../../../../new_commit_form'; -document.addEventListener('DOMContentLoaded', () => { - new ShortcutsNavigation(); // eslint-disable-line no-new - new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new - initBlob(); - initTree(); -}); +new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new +initBlob(); +initTree(); +new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js index 92d01343bd5..721219874cf 100644 --- a/app/assets/javascripts/pages/search/show/index.js +++ b/app/assets/javascripts/pages/search/show/index.js @@ -1,7 +1,7 @@ import Search from './search'; -import initStateFilter from '~/search/state_filter'; +import initSearchApp from '~/search'; document.addEventListener('DOMContentLoaded', () => { - initStateFilter(); + initSearchApp(); return new Search(); }); diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js index 6ff74325a5e..2cd333f26e1 100644 --- a/app/assets/javascripts/pages/search/show/search.js +++ b/app/assets/javascripts/pages/search/show/search.js @@ -4,6 +4,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import Api from '~/api'; import { __ } from '~/locale'; import Project from '~/pages/projects/project'; +import { visitUrl } from '~/lib/utils/url_utility'; import refreshCounts from './refresh_counts'; import setHighlightClass from './highlight_blob_search_result'; @@ -86,6 +87,10 @@ export default class Search { $(document) .off('click', this.searchClear) .on('click', this.searchClear, this.clearSearchField.bind(this)); + + $('a.js-search-clear') + .off('click', this.clearSearchFilter) + .on('click', this.clearSearchFilter); } static submitSearch() { @@ -108,6 +113,17 @@ export default class Search { .focus(); } + // We need to manually follow the link on the anchors + // that have this event bound, as their `click` default + // behavior is prevented by the toggle logic. + /* eslint-disable-next-line class-methods-use-this */ + clearSearchFilter(ev) { + const $target = $(ev.currentTarget); + + visitUrl($target.href); + ev.stopPropagation(); + } + getProjectsData(term) { return new Promise(resolve => { if (this.groupId) { diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js index 41d43812b5d..ab948fd106f 100644 --- a/app/assets/javascripts/pages/shared/wikis/wikis.js +++ b/app/assets/javascripts/pages/shared/wikis/wikis.js @@ -10,7 +10,7 @@ const MARKDOWN_LINK_TEXT = { }; const TRACKING_EVENT_NAME = 'view_wiki_page'; -const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-0'; +const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-1'; export default class Wikis { constructor() { diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 165feb1b6aa..e38771785b7 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -36,7 +36,7 @@ export default { metric: 'active-record', title: 'pg', header: s__('PerformanceBar|SQL queries'), - keys: ['sql'], + keys: ['sql', 'cached'], }, { metric: 'bullet', diff --git a/app/assets/javascripts/performance_bar/performance_bar_log.js b/app/assets/javascripts/performance_bar/performance_bar_log.js index 638c544f2e1..55b4d626e56 100644 --- a/app/assets/javascripts/performance_bar/performance_bar_log.js +++ b/app/assets/javascripts/performance_bar/performance_bar_log.js @@ -1,5 +1,6 @@ /* eslint-disable no-console */ import { getCLS, getFID, getLCP } from 'web-vitals'; +import { PERFORMANCE_TYPE_MARK, PERFORMANCE_TYPE_MEASURE } from '~/performance_constants'; const initVitalsLog = () => { const reportVital = data => { @@ -16,6 +17,29 @@ const initVitalsLog = () => { getLCP(reportVital); }; +const logUserTimingMetrics = () => { + const metricsProcessor = list => { + const entries = list.getEntries(); + entries.forEach(entry => { + const { name, entryType, startTime, duration } = entry; + const typeMapper = { + [PERFORMANCE_TYPE_MARK]: String.fromCodePoint(0x1f3af), + [PERFORMANCE_TYPE_MEASURE]: String.fromCodePoint(0x1f4d0), + }; + console.group(`${typeMapper[entryType]} ${name}`); + if (entryType === PERFORMANCE_TYPE_MARK) { + console.log(`Start time: ${startTime}`); + } else if (entryType === PERFORMANCE_TYPE_MEASURE) { + console.log(`Duration: ${duration}`); + } + console.log(entry); + console.groupEnd(); + }); + }; + const observer = new PerformanceObserver(metricsProcessor); + observer.observe({ entryTypes: [PERFORMANCE_TYPE_MEASURE, PERFORMANCE_TYPE_MARK] }); +}; + const initPerformanceBarLog = () => { console.log( `%c ${String.fromCodePoint(0x1f98a)} GitLab performance bar`, @@ -23,6 +47,7 @@ const initPerformanceBarLog = () => { ); initVitalsLog(); + logUserTimingMetrics(); }; export default initPerformanceBarLog; diff --git a/app/assets/javascripts/performance_constants.js b/app/assets/javascripts/performance_constants.js index 1a53b925aa4..6b6b6f1da40 100644 --- a/app/assets/javascripts/performance_constants.js +++ b/app/assets/javascripts/performance_constants.js @@ -1,12 +1,31 @@ +export const PERFORMANCE_TYPE_MARK = 'mark'; +export const PERFORMANCE_TYPE_MEASURE = 'measure'; + // // SNIPPET namespace // -// marks +// Marks export const SNIPPET_MARK_VIEW_APP_START = 'snippet-view-app-start'; export const SNIPPET_MARK_EDIT_APP_START = 'snippet-edit-app-start'; export const SNIPPET_MARK_BLOBS_CONTENT = 'snippet-blobs-content-finished'; // Measures export const SNIPPET_MEASURE_BLOBS_CONTENT = 'snippet-blobs-content'; -export const SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP = 'snippet-blobs-content-within-app'; + +// +// WebIDE namespace +// + +// Marks +export const WEBIDE_MARK_APP_START = 'webide-app-start'; +export const WEBIDE_MARK_TREE_START = 'webide-tree-start'; +export const WEBIDE_MARK_TREE_FINISH = 'webide-tree-finished'; +export const WEBIDE_MARK_FILE_START = 'webide-file-start'; +export const WEBIDE_MARK_FILE_CLICKED = 'webide-file-clicked'; +export const WEBIDE_MARK_FILE_FINISH = 'webide-file-finished'; + +// Measures +export const WEBIDE_MEASURE_TREE_FROM_REQUEST = 'webide-tree-loading-from-request'; +export const WEBIDE_MEASURE_FILE_FROM_REQUEST = 'webide-file-loading-from-request'; +export const WEBIDE_MEASURE_FILE_AFTER_INTERACTION = 'webide-file-loading-after-interaction'; diff --git a/app/assets/javascripts/performance_utils.js b/app/assets/javascripts/performance_utils.js new file mode 100644 index 00000000000..1c87ee2086e --- /dev/null +++ b/app/assets/javascripts/performance_utils.js @@ -0,0 +1,10 @@ +export const performanceMarkAndMeasure = ({ mark, measures = [] } = {}) => { + window.requestAnimationFrame(() => { + if (mark && !performance.getEntriesByName(mark).length) { + performance.mark(mark); + } + measures.forEach(measure => { + performance.measure(measure.name, measure.start, measure.end); + }); + }); +}; diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index be8ce832d20..20067f6646f 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -3,6 +3,7 @@ import Vue from 'vue'; import { uniqueId } from 'lodash'; import { GlAlert, + GlIcon, GlButton, GlForm, GlFormGroup, @@ -27,12 +28,13 @@ export default { variablesDescription: s__( 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', ), - formElementClasses: 'gl-mr-3 gl-mb-3 table-section section-15', + formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', errorTitle: __('The form contains the following error:'), warningTitle: __('The form contains the following warning:'), maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'), components: { GlAlert, + GlIcon, GlButton, GlForm, GlFormGroup, @@ -49,6 +51,10 @@ export default { type: String, required: true, }, + configVariablesPath: { + type: String, + required: true, + }, projectId: { type: String, required: true, @@ -85,7 +91,7 @@ export default { return { searchTerm: '', refValue: this.refParam, - variables: {}, + form: {}, error: null, warnings: [], totalWarnings: 0, @@ -97,9 +103,6 @@ export default { const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); return this.refs.filter(ref => ref.toLowerCase().includes(lowerCasedSearchTerm)); }, - variablesLength() { - return Object.keys(this.variables).length; - }, overMaxWarningsLimit() { return this.totalWarnings > this.maxWarnings; }, @@ -112,64 +115,135 @@ export default { shouldShowWarning() { return this.warnings.length > 0 && !this.isWarningDismissed; }, + variables() { + return this.form[this.refValue]?.variables ?? []; + }, + descriptions() { + return this.form[this.refValue]?.descriptions ?? {}; + }, }, created() { - if (this.variableParams) { - this.setVariableParams(VARIABLE_TYPE, this.variableParams); - } - - if (this.fileParams) { - this.setVariableParams(FILE_TYPE, this.fileParams); - } - - this.addEmptyVariable(); + this.setRefSelected(this.refValue); }, methods: { - addEmptyVariable() { - this.variables[uniqueId('var')] = { + addEmptyVariable(refValue) { + const { variables } = this.form[refValue]; + + const lastVar = variables[variables.length - 1]; + if (lastVar?.key === '' && lastVar?.value === '') { + return; + } + + variables.push({ + uniqueId: uniqueId(`var-${refValue}`), variable_type: VARIABLE_TYPE, key: '', value: '', - }; + }); }, - setVariableParams(type, paramsObj) { - Object.entries(paramsObj).forEach(([key, value]) => { - this.variables[uniqueId('var')] = { + setVariable(refValue, type, key, value) { + const { variables } = this.form[refValue]; + + const variable = variables.find(v => v.key === key); + if (variable) { + variable.type = type; + variable.value = value; + } else { + variables.push({ + uniqueId: uniqueId(`var-${refValue}`), key, value, variable_type: type, - }; + }); + } + }, + setVariableParams(refValue, type, paramsObj) { + Object.entries(paramsObj).forEach(([key, value]) => { + this.setVariable(refValue, type, key, value); }); }, - setRefSelected(ref) { - this.refValue = ref; + setRefSelected(refValue) { + this.refValue = refValue; + + if (!this.form[refValue]) { + this.fetchConfigVariables(refValue) + .then(({ descriptions, params }) => { + Vue.set(this.form, refValue, { + variables: [], + descriptions, + }); + + // Add default variables from yml + this.setVariableParams(refValue, VARIABLE_TYPE, params); + }) + .catch(() => { + Vue.set(this.form, refValue, { + variables: [], + descriptions: {}, + }); + }) + .finally(() => { + // Add/update variables, e.g. from query string + if (this.variableParams) { + this.setVariableParams(refValue, VARIABLE_TYPE, this.variableParams); + } + if (this.fileParams) { + this.setVariableParams(refValue, FILE_TYPE, this.fileParams); + } + + // Adds empty var at the end of the form + this.addEmptyVariable(refValue); + }); + } }, + isSelected(ref) { return ref === this.refValue; }, - insertNewVariable() { - Vue.set(this.variables, uniqueId('var'), { - variable_type: VARIABLE_TYPE, - key: '', - value: '', - }); + removeVariable(index) { + this.variables.splice(index, 1); }, - removeVariable(key) { - Vue.delete(this.variables, key); + canRemove(index) { + return index < this.variables.length - 1; }, - canRemove(index) { - return index < this.variablesLength - 1; + fetchConfigVariables(refValue) { + if (gon?.features?.newPipelineFormPrefilledVars) { + return axios + .get(this.configVariablesPath, { + params: { + sha: refValue, + }, + }) + .then(({ data }) => { + const params = {}; + const descriptions = {}; + + Object.entries(data).forEach(([key, { value, description }]) => { + if (description !== null) { + params[key] = value; + descriptions[key] = description; + } + }); + + return { params, descriptions }; + }); + } + return Promise.resolve({ params: {}, descriptions: {} }); }, createPipeline() { - const filteredVariables = Object.values(this.variables).filter( - ({ key, value }) => key !== '' && value !== '', - ); + const filteredVariables = this.variables + .filter(({ key, value }) => key !== '' && value !== '') + .map(({ variable_type, key, value }) => ({ + variable_type, + key, + secret_value: value, + })); return axios .post(this.pipelinesPath, { ref: this.refValue, - variables: filteredVariables, + variables_attributes: filteredVariables, }) .then(({ data }) => { redirectTo(`${this.pipelinesPath}/${data.id}`); @@ -230,7 +304,6 @@ export default { <gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search branches and tags')" - class="gl-p-2" /> <gl-dropdown-item v-for="(ref, index) in filteredRefs" @@ -253,35 +326,55 @@ export default { <gl-form-group :label="s__('Pipeline|Variables')"> <div - v-for="(value, key, index) in variables" - :key="key" - class="gl-display-flex gl-align-items-center gl-mb-4 gl-pb-2 gl-border-b-solid gl-border-gray-200 gl-border-b-1 gl-flex-direction-column gl-md-flex-direction-row" + v-for="(variable, index) in variables" + :key="variable.uniqueId" + class="gl-mb-3 gl-ml-n3 gl-pb-2" data-testid="ci-variable-row" > - <gl-form-select - v-model="variables[key].variable_type" - :class="$options.formElementClasses" - :options="$options.typeOptions" - /> - <gl-form-input - v-model="variables[key].key" - :placeholder="s__('CiVariables|Input variable key')" - :class="$options.formElementClasses" - data-testid="pipeline-form-ci-variable-key" - @change.once="insertNewVariable()" - /> - <gl-form-input - v-model="variables[key].value" - :placeholder="s__('CiVariables|Input variable value')" - class="gl-mr-5 gl-mb-3 table-section section-15" - /> - <gl-button - v-if="canRemove(index)" - icon="issue-close" - class="gl-mb-3" - data-testid="remove-ci-variable-row" - @click="removeVariable(key)" - /> + <div + class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" + > + <gl-form-select + v-model="variable.variable_type" + :class="$options.formElementClasses" + :options="$options.typeOptions" + /> + <gl-form-input + v-model="variable.key" + :placeholder="s__('CiVariables|Input variable key')" + :class="$options.formElementClasses" + data-testid="pipeline-form-ci-variable-key" + @change="addEmptyVariable(refValue)" + /> + <gl-form-input + v-model="variable.value" + :placeholder="s__('CiVariables|Input variable value')" + class="gl-mb-3" + data-testid="pipeline-form-ci-variable-value" + /> + + <template v-if="variables.length > 1"> + <gl-button + v-if="canRemove(index)" + class="gl-md-ml-3 gl-mb-3" + data-testid="remove-ci-variable-row" + variant="danger" + category="secondary" + @click="removeVariable(index)" + > + <gl-icon class="gl-mr-0! gl-display-none gl-display-md-block" name="clear" /> + <span class="gl-display-md-none">{{ s__('CiVariables|Remove variable') }}</span> + </gl-button> + <gl-button + v-else + class="gl-md-ml-3 gl-mb-3 gl-display-none gl-display-md-block gl-visibility-hidden" + icon="clear" + /> + </template> + </div> + <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3"> + {{ descriptions[variable.key] }} + </div> </div> <template #description @@ -295,9 +388,14 @@ export default { <div class="gl-border-t-solid gl-border-gray-100 gl-border-t-1 gl-p-5 gl-bg-gray-10 gl-display-flex gl-justify-content-space-between" > - <gl-button type="submit" category="primary" variant="success">{{ - s__('Pipeline|Run Pipeline') - }}</gl-button> + <gl-button + type="submit" + category="primary" + variant="success" + class="js-no-auto-disable" + data-qa-selector="run_pipeline_button" + >{{ s__('Pipeline|Run Pipeline') }}</gl-button + > <gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button> </div> </gl-form> diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js index f1ea86f8c5f..ff4f677654e 100644 --- a/app/assets/javascripts/pipeline_new/index.js +++ b/app/assets/javascripts/pipeline_new/index.js @@ -6,6 +6,7 @@ export default () => { const { projectId, pipelinesPath, + configVariablesPath, refParam, varParam, fileParam, @@ -25,6 +26,7 @@ export default () => { props: { projectId, pipelinesPath, + configVariablesPath, refParam, variableParams, fileParams, diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js index b6a98fdc488..cd89055737f 100644 --- a/app/assets/javascripts/pipelines/components/dag/constants.js +++ b/app/assets/javascripts/pipelines/components/dag/constants.js @@ -1,9 +1,3 @@ -/* Error constants */ -export const PARSE_FAILURE = 'parse_failure'; -export const LOAD_FAILURE = 'load_failure'; -export const UNSUPPORTED_DATA = 'unsupported_data'; -export const DEFAULT = 'default'; - /* Interaction handles */ export const IS_HIGHLIGHTED = 'dag-highlighted'; export const LINK_SELECTOR = 'dag-link'; diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index 8487da3d621..6267b63328c 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -6,16 +6,9 @@ import { fetchPolicies } from '~/lib/graphql'; import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql'; import DagGraph from './dag_graph.vue'; import DagAnnotations from './dag_annotations.vue'; -import { - DEFAULT, - PARSE_FAILURE, - LOAD_FAILURE, - UNSUPPORTED_DATA, - ADD_NOTE, - REMOVE_NOTE, - REPLACE_NOTES, -} from './constants'; -import { parseData } from './parsing_utils'; +import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants'; +import { parseData } from '../parsing_utils'; +import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from '../../constants'; export default { // eslint-disable-next-line @gitlab/require-i18n-strings diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue index d12baa9617e..42d1debcddf 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue @@ -1,14 +1,7 @@ <script> import * as d3 from 'd3'; import { uniqueId } from 'lodash'; -import { - LINK_SELECTOR, - NODE_SELECTOR, - PARSE_FAILURE, - ADD_NOTE, - REMOVE_NOTE, - REPLACE_NOTES, -} from './constants'; +import { LINK_SELECTOR, NODE_SELECTOR, ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants'; import { currentIsLive, getLiveLinksAsDict, @@ -17,8 +10,9 @@ import { toggleLinkHighlight, togglePathHighlights, } from './interactions'; -import { getMaxNodes, removeOrphanNodes } from './parsing_utils'; +import { getMaxNodes, removeOrphanNodes } from '../parsing_utils'; import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils'; +import { PARSE_FAILURE } from '../../constants'; export default { viewOptions: { diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index efa11580c41..a580ee11627 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -88,7 +88,7 @@ export default { :class="cssClass" :disabled="isDisabled" class="js-ci-action ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center" - @click="onClickAction" + @click.stop="onClickAction" > <gl-loading-icon v-if="isLoading" class="js-action-icon-loading" /> <gl-icon v-else :name="actionIcon" class="gl-mr-0!" /> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 924cdeebba1..0f5a8cb8fbf 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,7 +1,7 @@ <script> +import { escape, capitalize } from 'lodash'; import { GlLoadingIcon } from '@gitlab/ui'; import StageColumnComponent from './stage_column_component.vue'; -import GraphMixin from '../../mixins/graph_component_mixin'; import GraphWidthMixin from '../../mixins/graph_width_mixin'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; @@ -13,7 +13,7 @@ export default { GlLoadingIcon, LinkedPipelinesColumn, }, - mixins: [GraphMixin, GraphWidthMixin, GraphBundleMixin], + mixins: [GraphWidthMixin, GraphBundleMixin], props: { isLoading: { type: Boolean, @@ -51,6 +51,9 @@ export default { }; }, computed: { + graph() { + return this.pipeline.details?.stages; + }, hasTriggeredBy() { return ( this.type !== this.$options.downstream && @@ -92,6 +95,39 @@ export default { }, }, methods: { + capitalizeStageName(name) { + const escapedName = escape(name); + return capitalize(escapedName); + }, + isFirstColumn(index) { + return index === 0; + }, + stageConnectorClass(index, stage) { + let className; + + // If it's the first stage column and only has one job + if (this.isFirstColumn(index) && stage.groups.length === 1) { + className = 'no-margin'; + } else if (index > 0) { + // If it is not the first column + className = 'left-margin'; + } + + return className; + }, + refreshPipelineGraph() { + this.$emit('refreshPipelineGraph'); + }, + /** + * CSS class is applied: + * - if pipeline graph contains only one stage column component + * + * @param {number} index + * @returns {boolean} + */ + shouldAddRightMargin(index) { + return !(index === this.graph.length - 1); + }, handleClickedDownstream(pipeline, clickedIndex, downstreamNode) { /** * Calculates the margin top of the clicked downstream pipeline by diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index 11fb2b18e9d..49591a80752 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -1,5 +1,4 @@ <script> -import $ from 'jquery'; import { GlTooltipDirective } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import JobItem from './job_item.vue'; @@ -30,27 +29,7 @@ export default { return `${name} - ${status.label}`; }, }, - mounted() { - this.stopDropdownClickPropagation(); - }, methods: { - /** - * When the user right clicks or cmd/ctrl + click in the group name or the action icon - * the dropdown should not be closed so we stop propagation - * of the click event inside the dropdown. - * - * Since this component is rendered multiple times per page we need to guarantee we only - * target the click event of this component. - */ - stopDropdownClickPropagation() { - $( - '.js-grouped-pipeline-dropdown button, .js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item', - this.$el, - ).on('click', e => { - e.stopPropagation(); - }); - }, - pipelineActionRequestComplete() { this.$emit('pipelineActionRequestComplete'); }, @@ -69,7 +48,9 @@ export default { > <ci-icon :status="group.status" /> - <span class="ci-status-text text-truncate mw-70p gl-pl-2 d-inline-block align-bottom"> + <span + class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom" + > {{ group.name }} </span> diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 0fe0b671273..7aee2573ce1 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -126,7 +126,7 @@ export default { }; </script> <template> - <div class="ci-job-component"> + <div class="ci-job-component" data-qa-selector="job_item_container"> <gl-link v-if="status.has_details" v-gl-tooltip="{ boundary, placement: 'bottom' }" @@ -135,6 +135,7 @@ export default { :class="jobClasses" class="js-pipeline-graph-job-link qa-job-link menu-item" data-testid="job-with-link" + @click.stop > <job-name-component :name="job.name" :status="job.status" /> </gl-link> @@ -155,6 +156,7 @@ export default { :tooltip-text="status.action.title" :link="status.action.path" :action-icon="status.action.icon" + data-qa-selector="action_button" @pipelineActionRequestComplete="pipelineActionRequestComplete" /> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue index 30ba243077e..1b71949784a 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -27,7 +27,7 @@ export default { <template> <span class="ci-job-name-component mw-100"> <ci-icon :status="status" /> - <span class="ci-status-text text-truncate mw-70p gl-pl-2 d-inline-block align-bottom"> + <span class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom"> {{ name }} </span> </span> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index 1453c349f44..a75ec585b95 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -71,7 +71,7 @@ export default { :action-icon="action.icon" :tooltip-text="action.title" :link="action.path" - class="js-stage-action stage-action position-absolute position-top-0 rounded" + class="js-stage-action stage-action rounded" @pipelineActionRequestComplete="pipelineActionRequestComplete" /> </div> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index c7b72be36ad..b26f28fa6af 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,8 +1,11 @@ <script> -import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui'; -import ciHeader from '~/vue_shared/components/header_ci_component.vue'; -import eventHub from '../event_hub'; +import { GlAlert, GlButton, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import ciHeader from '~/vue_shared/components/header_ci_component.vue'; +import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; +import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql'; +import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants'; const DELETE_MODAL_ID = 'pipeline-delete-modal'; @@ -10,57 +13,143 @@ export default { name: 'PipelineHeaderSection', components: { ciHeader, + GlAlert, + GlButton, GlLoadingIcon, GlModal, - GlButton, }, directives: { GlModal: GlModalDirective, }, - props: { - pipeline: { - type: Object, - required: true, + errorTexts: { + [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'), + [POST_FAILURE]: __('An error occurred while making the request.'), + [DELETE_FAILURE]: __('An error occurred while deleting the pipeline.'), + [DEFAULT]: __('An unknown error occurred.'), + }, + inject: { + // Receive `cancel`, `delete`, `fullProject` and `retry` + paths: { + default: {}, + }, + pipelineId: { + default: '', }, - isLoading: { - type: Boolean, - required: true, + pipelineIid: { + default: '', + }, + }, + apollo: { + pipeline: { + query: getPipelineQuery, + variables() { + return { + fullPath: this.paths.fullProject, + iid: this.pipelineIid, + }; + }, + update: data => data.project.pipeline, + error() { + this.reportFailure(LOAD_FAILURE); + }, + pollInterval: 10000, + watchLoading(isLoading) { + if (!isLoading) { + // To ensure apollo has updated the cache, + // we only remove the loading state in sync with GraphQL + this.isCanceling = false; + this.isRetrying = false; + } + }, }, }, data() { return { + pipeline: null, + failureType: null, isCanceling: false, isRetrying: false, isDeleting: false, }; }, - computed: { - status() { - return this.pipeline.details && this.pipeline.details.status; - }, - shouldRenderContent() { - return !this.isLoading && Object.keys(this.pipeline).length; - }, deleteModalConfirmationText() { return __( 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.', ); }, + hasError() { + return this.failureType; + }, + hasPipelineData() { + return Boolean(this.pipeline); + }, + isLoadingInitialQuery() { + return this.$apollo.queries.pipeline.loading && !this.hasPipelineData; + }, + status() { + return this.pipeline?.status; + }, + shouldRenderContent() { + return !this.isLoadingInitialQuery && this.hasPipelineData; + }, + failure() { + switch (this.failureType) { + case LOAD_FAILURE: + return { + text: this.$options.errorTexts[LOAD_FAILURE], + variant: 'danger', + }; + case POST_FAILURE: + return { + text: this.$options.errorTexts[POST_FAILURE], + variant: 'danger', + }; + case DELETE_FAILURE: + return { + text: this.$options.errorTexts[DELETE_FAILURE], + variant: 'danger', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT], + variant: 'danger', + }; + } + }, }, - methods: { - cancelPipeline() { + reportFailure(errorType) { + this.failureType = errorType; + }, + async postAction(path) { + try { + await axios.post(path); + this.$apollo.queries.pipeline.refetch(); + } catch { + this.reportFailure(POST_FAILURE); + } + }, + async cancelPipeline() { this.isCanceling = true; - eventHub.$emit('headerPostAction', this.pipeline.cancel_path); + this.postAction(this.paths.cancel); }, - retryPipeline() { + async retryPipeline() { this.isRetrying = true; - eventHub.$emit('headerPostAction', this.pipeline.retry_path); + this.postAction(this.paths.retry); }, - deletePipeline() { + async deletePipeline() { this.isDeleting = true; - eventHub.$emit('headerDeleteAction', this.pipeline.delete_path); + this.$apollo.queries.pipeline.stopPolling(); + + try { + const { request } = await axios.delete(this.paths.delete); + redirectTo(setUrlFragment(request.responseURL, 'delete_success')); + } catch { + this.$apollo.queries.pipeline.startPolling(); + this.reportFailure(DELETE_FAILURE); + this.isDeleting = false; + } }, }, DELETE_MODAL_ID, @@ -68,54 +157,53 @@ export default { </script> <template> <div class="pipeline-header-container"> + <gl-alert v-if="hasError" :variant="failure.variant">{{ failure.text }}</gl-alert> <ci-header v-if="shouldRenderContent" - :status="status" - :item-id="pipeline.id" - :time="pipeline.created_at" + :status="pipeline.detailedStatus" + :time="pipeline.createdAt" :user="pipeline.user" + :item-id="Number(pipelineId)" item-name="Pipeline" > <gl-button - v-if="pipeline.retry_path" + v-if="pipeline.retryable" :loading="isRetrying" :disabled="isRetrying" - data-testid="retryButton" category="secondary" variant="info" + data-testid="retryPipeline" + class="js-retry-button" @click="retryPipeline()" > {{ __('Retry') }} </gl-button> <gl-button - v-if="pipeline.cancel_path" + v-if="pipeline.cancelable" :loading="isCanceling" :disabled="isCanceling" - data-testid="cancelPipeline" - class="gl-ml-3" - category="primary" variant="danger" + data-testid="cancelPipeline" @click="cancelPipeline()" > {{ __('Cancel running') }} </gl-button> <gl-button - v-if="pipeline.delete_path" + v-if="pipeline.userPermissions.destroyPipeline" v-gl-modal="$options.DELETE_MODAL_ID" :loading="isDeleting" :disabled="isDeleting" - data-testid="deletePipeline" class="gl-ml-3" - category="secondary" variant="danger" + category="secondary" + data-testid="deletePipeline" > {{ __('Delete') }} </gl-button> </ci-header> - - <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" /> + <gl-loading-icon v-if="isLoadingInitialQuery" size="lg" class="gl-mt-3 gl-mb-3" /> <gl-modal :modal-id="$options.DELETE_MODAL_ID" diff --git a/app/assets/javascripts/pipelines/components/legacy_header_component.vue b/app/assets/javascripts/pipelines/components/legacy_header_component.vue new file mode 100644 index 00000000000..c7b72be36ad --- /dev/null +++ b/app/assets/javascripts/pipelines/components/legacy_header_component.vue @@ -0,0 +1,132 @@ +<script> +import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui'; +import ciHeader from '~/vue_shared/components/header_ci_component.vue'; +import eventHub from '../event_hub'; +import { __ } from '~/locale'; + +const DELETE_MODAL_ID = 'pipeline-delete-modal'; + +export default { + name: 'PipelineHeaderSection', + components: { + ciHeader, + GlLoadingIcon, + GlModal, + GlButton, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + pipeline: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: true, + }, + }, + data() { + return { + isCanceling: false, + isRetrying: false, + isDeleting: false, + }; + }, + + computed: { + status() { + return this.pipeline.details && this.pipeline.details.status; + }, + shouldRenderContent() { + return !this.isLoading && Object.keys(this.pipeline).length; + }, + deleteModalConfirmationText() { + return __( + 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.', + ); + }, + }, + + methods: { + cancelPipeline() { + this.isCanceling = true; + eventHub.$emit('headerPostAction', this.pipeline.cancel_path); + }, + retryPipeline() { + this.isRetrying = true; + eventHub.$emit('headerPostAction', this.pipeline.retry_path); + }, + deletePipeline() { + this.isDeleting = true; + eventHub.$emit('headerDeleteAction', this.pipeline.delete_path); + }, + }, + DELETE_MODAL_ID, +}; +</script> +<template> + <div class="pipeline-header-container"> + <ci-header + v-if="shouldRenderContent" + :status="status" + :item-id="pipeline.id" + :time="pipeline.created_at" + :user="pipeline.user" + item-name="Pipeline" + > + <gl-button + v-if="pipeline.retry_path" + :loading="isRetrying" + :disabled="isRetrying" + data-testid="retryButton" + category="secondary" + variant="info" + @click="retryPipeline()" + > + {{ __('Retry') }} + </gl-button> + + <gl-button + v-if="pipeline.cancel_path" + :loading="isCanceling" + :disabled="isCanceling" + data-testid="cancelPipeline" + class="gl-ml-3" + category="primary" + variant="danger" + @click="cancelPipeline()" + > + {{ __('Cancel running') }} + </gl-button> + + <gl-button + v-if="pipeline.delete_path" + v-gl-modal="$options.DELETE_MODAL_ID" + :loading="isDeleting" + :disabled="isDeleting" + data-testid="deletePipeline" + class="gl-ml-3" + category="secondary" + variant="danger" + > + {{ __('Delete') }} + </gl-button> + </ci-header> + + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" /> + + <gl-modal + :modal-id="$options.DELETE_MODAL_ID" + :title="__('Delete pipeline')" + :ok-title="__('Delete pipeline')" + ok-variant="danger" + @ok="deletePipeline()" + > + <p> + {{ deleteModalConfirmationText }} + </p> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js index 1ed415688f2..1ed415688f2 100644 --- a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/parsing_utils.js diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js b/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js new file mode 100644 index 00000000000..45940d4a39c --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js @@ -0,0 +1,96 @@ +import * as d3 from 'd3'; +import { createUniqueJobId } from '../../utils'; +/** + * This function expects its first argument data structure + * to be the same shaped as the one generated by `parseData`, + * which contains nodes and links. For each link, + * we find the nodes in the graph, calculate their coordinates and + * trace the lines that represent the needs of each job. + * @param {Object} nodeDict - Resulting object of `parseData` with nodes and links + * @param {Object} jobs - An object where each key is the job name that contains the job data + * @param {ref} svg - Reference to the svg we draw in + * @returns {Array} Links that contain all the information about them + */ + +export const generateLinksData = ({ links }, jobs, containerID) => { + const containerEl = document.getElementById(containerID); + return links.map(link => { + const path = d3.path(); + + const sourceId = jobs[link.source].id; + const targetId = jobs[link.target].id; + + const sourceNodeEl = document.getElementById(sourceId); + const targetNodeEl = document.getElementById(targetId); + + const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect(); + const targetNodeCoordinates = targetNodeEl.getBoundingClientRect(); + const containerCoordinates = containerEl.getBoundingClientRect(); + + // Because we add the svg dynamically and calculate the coordinates + // with plain JS and not D3, we need to account for the fact that + // the coordinates we are getting are absolutes, but we want to draw + // relative to the svg container, which starts at `containerCoordinates(x,y)` + // so we substract these from the total. We also need to remove the padding + // from the total to make sure it's aligned properly. We then make the line + // positioned in the center of the job node by adding half the height + // of the job pill. + const paddingLeft = Number( + window + .getComputedStyle(containerEl, null) + .getPropertyValue('padding-left') + .replace('px', ''), + ); + const paddingTop = Number( + window + .getComputedStyle(containerEl, null) + .getPropertyValue('padding-top') + .replace('px', ''), + ); + + const sourceNodeX = sourceNodeCoordinates.right - containerCoordinates.x - paddingLeft; + const sourceNodeY = + sourceNodeCoordinates.top - + containerCoordinates.y - + paddingTop + + sourceNodeCoordinates.height / 2; + const targetNodeX = targetNodeCoordinates.x - containerCoordinates.x - paddingLeft; + const targetNodeY = + targetNodeCoordinates.y - + containerCoordinates.y - + paddingTop + + sourceNodeCoordinates.height / 2; + + // Start point + path.moveTo(sourceNodeX, sourceNodeY); + + // Make cross-stages lines a straight line all the way + // until we can safely draw the bezier to look nice. + const straightLineDestinationX = targetNodeX - 100; + const controlPointX = straightLineDestinationX + (targetNodeX - straightLineDestinationX) / 2; + + if (straightLineDestinationX > 0) { + path.lineTo(straightLineDestinationX, sourceNodeY); + } + + // Add bezier curve. The first 4 coordinates are the 2 control + // points to create the curve, and the last one is the end point (x, y). + // We want our control points to be in the middle of the line + path.bezierCurveTo( + controlPointX, + sourceNodeY, + controlPointX, + targetNodeY, + targetNodeX, + targetNodeY, + ); + + return { + ...link, + source: sourceId, + target: targetId, + ref: createUniqueJobId(sourceId, targetId), + path: path.toString(), + }; + }); +}; diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue index 19d41b166c3..8eec0110865 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue @@ -10,13 +10,57 @@ export default { type: String, required: true, }, + jobId: { + type: String, + required: true, + }, + isHighlighted: { + type: Boolean, + required: false, + default: false, + }, + isFadedOut: { + type: Boolean, + required: false, + default: false, + }, + handleMouseOver: { + type: Function, + required: false, + default: () => {}, + }, + handleMouseLeave: { + type: Function, + required: false, + default: () => {}, + }, + }, + computed: { + jobPillClasses() { + return [ + { 'gl-opacity-3': this.isFadedOut }, + this.isHighlighted ? 'gl-shadow-blue-200-x0-y0-b4-s2' : 'gl-inset-border-2-green-400', + ]; + }, + }, + methods: { + onMouseEnter() { + this.$emit('on-mouse-enter', this.jobId); + }, + onMouseLeave() { + this.$emit('on-mouse-leave'); + }, }, }; </script> <template> <tooltip-on-truncate :title="jobName" truncate-target="child" placement="top"> <div - class="gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-inset-border-1-green-600 gl-mb-3 gl-px-5 gl-py-2 pipeline-job-pill " + :id="jobId" + class="pipeline-job-pill gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease" + :class="jobPillClasses" + @mouseover="onMouseEnter" + @mouseleave="onMouseLeave" > {{ jobName }} </div> diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue index 6a0d3cce1f3..3a2b8a20bae 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -1,8 +1,13 @@ <script> import { isEmpty } from 'lodash'; import { GlAlert } from '@gitlab/ui'; +import { __ } from '~/locale'; import JobPill from './job_pill.vue'; import StagePill from './stage_pill.vue'; +import { generateLinksData } from './drawing_utils'; +import { parseData } from '../parsing_utils'; +import { DRAW_FAILURE, DEFAULT } from '../../constants'; +import { generateJobNeedsDict } from '../../utils'; export default { components: { @@ -10,28 +15,174 @@ export default { JobPill, StagePill, }, + CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF', + CONTAINER_ID: 'pipeline-graph-container', + STROKE_WIDTH: 2, + errorTexts: { + [DRAW_FAILURE]: __('Could not draw the lines for job relationships'), + [DEFAULT]: __('An unknown error occurred.'), + }, props: { pipelineData: { required: true, type: Object, }, }, + data() { + return { + failureType: null, + highlightedJob: null, + links: [], + needsObject: null, + height: 0, + width: 0, + }; + }, computed: { isPipelineDataEmpty() { return isEmpty(this.pipelineData); }, - emptyClass() { - return !this.isPipelineDataEmpty ? 'gl-py-7' : ''; + hasError() { + return this.failureType; + }, + hasHighlightedJob() { + return Boolean(this.highlightedJob); + }, + failure() { + const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT]; + + return { text, variant: 'danger' }; + }, + viewBox() { + return [0, 0, this.width, this.height]; + }, + highlightedJobs() { + // If you are hovering on a job, then the jobs we want to highlight are: + // The job you are currently hovering + all of its needs. + return this.hasHighlightedJob + ? [this.highlightedJob, ...this.needsObject[this.highlightedJob]] + : []; + }, + highlightedLinks() { + // If you are hovering on a job, then the links we want to highlight are: + // All the links whose `source` and `target` are highlighted jobs. + if (this.hasHighlightedJob) { + const filteredLinks = this.links.filter(link => { + return ( + this.highlightedJobs.includes(link.source) && this.highlightedJobs.includes(link.target) + ); + }); + + return filteredLinks.map(link => link.ref); + } + + return []; + }, + }, + mounted() { + if (!this.isPipelineDataEmpty) { + this.getGraphDimensions(); + this.drawJobLinks(); + } + }, + methods: { + drawJobLinks() { + const { stages, jobs } = this.pipelineData; + const unwrappedGroups = this.unwrapPipelineData(stages); + + try { + const parsedData = parseData(unwrappedGroups); + this.links = generateLinksData(parsedData, jobs, this.$options.CONTAINER_ID); + } catch { + this.reportFailure(DRAW_FAILURE); + } + }, + getStageBackgroundClass(index) { + const { length } = this.pipelineData.stages; + + if (length === 1) { + return 'stage-rounded'; + } else if (index === 0) { + return 'stage-left-rounded'; + } else if (index === length - 1) { + return 'stage-right-rounded'; + } + + return ''; + }, + highlightNeeds(uniqueJobId) { + // The first time we hover, we create the object where + // we store all the data to properly highlight the needs. + if (!this.needsObject) { + this.needsObject = generateJobNeedsDict(this.pipelineData) ?? {}; + } + + this.highlightedJob = uniqueJobId; + }, + removeHighlightNeeds() { + this.highlightedJob = null; + }, + unwrapPipelineData(stages) { + return stages + .map(({ name, groups }) => { + return groups.map(group => { + return { category: name, ...group }; + }); + }) + .flat(2); + }, + getGraphDimensions() { + this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}px`; + this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}px`; + }, + reportFailure(errorType) { + this.failureType = errorType; + }, + resetFailure() { + this.failureType = null; + }, + isJobHighlighted(jobName) { + return this.highlightedJobs.includes(jobName); + }, + isLinkHighlighted(linkRef) { + return this.highlightedLinks.includes(linkRef); + }, + getLinkClasses(link) { + return [ + this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : 'gl-stroke-gray-200', + { 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) }, + ]; }, }, }; </script> <template> - <div class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto" :class="emptyClass"> + <div> + <gl-alert v-if="hasError" :variant="failure.variant" @dismiss="resetFailure"> + {{ failure.text }} + </gl-alert> <gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false"> {{ __('No content to show') }} </gl-alert> - <template v-else> + <div + v-else + :id="$options.CONTAINER_ID" + :ref="$options.CONTAINER_REF" + class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7" + > + <svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute"> + <template> + <path + v-for="link in links" + :key="link.path" + :ref="link.ref" + :d="link.path" + class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease" + :class="getLinkClasses(link)" + :stroke-width="$options.STROKE_WIDTH" + /> + </template> + </svg> <div v-for="(stage, index) in pipelineData.stages" :key="`${stage.name}-${index}`" @@ -39,19 +190,25 @@ export default { > <div class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5" - :class="{ - 'stage-left-rounded': index === 0, - 'stage-right-rounded': index === pipelineData.stages.length - 1, - }" + :class="getStageBackgroundClass(index)" > <stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" /> </div> <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8" > - <job-pill v-for="group in stage.groups" :key="group.name" :job-name="group.name" /> + <job-pill + v-for="group in stage.groups" + :key="group.name" + :job-id="group.id" + :job-name="group.name" + :is-highlighted="hasHighlightedJob && isJobHighlighted(group.id)" + :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.id)" + @on-mouse-enter="highlightNeeds" + @on-mouse-leave="removeHighlightNeeds" + /> </div> </div> - </template> + </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index fe8e3bd2b78..c5f30c8aef0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -48,6 +48,7 @@ export default { variant="info" category="primary" class="js-get-started-pipelines" + data-testid="get-started-pipelines" > {{ s__('Pipelines|Get started with Pipelines') }} </gl-button> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue index d7b6e033bd1..cf0849751df 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue @@ -46,6 +46,8 @@ export default { variant="success" category="primary" class="js-run-pipeline" + data-testid="run-pipeline-button" + data-qa-selector="run_pipeline_button" > {{ s__('Pipelines|Run Pipeline') }} </gl-button> @@ -54,12 +56,13 @@ export default { v-if="resetCachePath" :loading="isResetCacheButtonLoading" class="js-clear-cache" + data-testid="clear-cache-button" @click="onClickResetCache" > {{ s__('Pipelines|Clear Runner Caches') }} </gl-button> - <gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint"> + <gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint" data-testid="ci-lint-button"> {{ s__('Pipelines|CI Lint') }} </gl-button> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue index 43a54090e18..1569b326b31 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue @@ -1,10 +1,9 @@ <script> /* eslint-disable vue/no-v-html */ import { isEmpty } from 'lodash'; -import { GlLink } from '@gitlab/ui'; -import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; +import { GlLink, GlModal } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; /** * Pipeline Stop Modal. @@ -13,7 +12,7 @@ import { s__, sprintf } from '~/locale'; */ export default { components: { - GlModal: DeprecatedModal2, + GlModal, GlLink, CiIcon, }, @@ -46,6 +45,17 @@ export default { hasRef() { return !isEmpty(this.pipeline.ref); }, + primaryProps() { + return { + text: s__('Pipeline|Stop pipeline'), + attributes: [{ variant: 'danger' }], + }; + }, + cancelProps() { + return { + text: __('Cancel'), + }; + }, }, methods: { emitSubmit(event) { @@ -56,11 +66,11 @@ export default { </script> <template> <gl-modal - id="confirmation-modal" - :header-title-text="modalTitle" - :footer-primary-button-text="s__('Pipeline|Stop pipeline')" - footer-primary-button-variant="danger" - @submit="emitSubmit($event)" + modal-id="confirmation-modal" + :title="modalTitle" + :action-primary="primaryProps" + :action-cancel="cancelProps" + @primary="emitSubmit($event)" > <p v-html="modalText"></p> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue index 35fd9837b3e..6ac60727f23 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue @@ -19,7 +19,7 @@ export default { }; </script> <template> - <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-triggerer"> + <div class="table-section section-10 d-none d-md-block pipeline-triggerer"> <user-avatar-link v-if="user" :link-href="user.path" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index f0614298bd3..63262cc79fd 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -36,7 +36,7 @@ export default { }; </script> <template> - <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-tags"> + <div class="table-section section-10 d-none d-md-block pipeline-tags"> <gl-link :href="pipeline.path" class="js-pipeline-url-link js-onboarding-pipeline-item" @@ -98,7 +98,7 @@ export default { placement="top" > <template #title> - <div class="autodevops-title"> + <div class="gl-font-weight-normal gl-line-height-normal"> <gl-sprintf :message=" __( @@ -112,12 +112,7 @@ export default { </gl-sprintf> </div> </template> - <gl-link - class="autodevops-link" - :href="autoDevopsHelpPath" - target="_blank" - rel="noopener noreferrer nofollow" - > + <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow"> {{ __('Learn more about Auto DevOps') }} </gl-link> </gl-popover> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index b8112149778..6c60594efca 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -91,6 +91,10 @@ export default { <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader"> {{ s__('Pipeline|Stages') }} </div> + <div class="table-section section-15" role="rowheader"></div> + <div class="table-section section-20" role="rowheader"> + <slot name="table-header-actions"></slot> + </div> </div> <pipelines-table-row-component v-for="model in pipelines" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue index 1bdb7d18f04..7224ec455f6 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue @@ -1,6 +1,7 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; import eventHub from '../../event_hub'; +import { __ } from '~/locale'; import PipelinesActionsComponent from './pipelines_actions.vue'; import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; @@ -17,6 +18,14 @@ import { PIPELINES_TABLE } from '../../constants'; * Given the received object renders a table row in the pipelines' table. */ export default { + i18n: { + cancelTitle: __('Cancel'), + redeployTitle: __('Retry'), + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModalDirective, + }, components: { PipelinesActionsComponent, PipelinesArtifactsComponent, @@ -321,7 +330,11 @@ export default { </div> </div> - <pipelines-timeago :duration="pipelineDuration" :finished-time="pipelineFinishedAt" /> + <pipelines-timeago + class="gl-text-right" + :duration="pipelineDuration" + :finished-time="pipelineFinishedAt" + /> <div v-if="displayPipelineActions" @@ -338,8 +351,11 @@ export default { <gl-button v-if="pipeline.flags.retryable" - :loading="isRetrying" + v-gl-tooltip.hover + :aria-label="$options.i18n.redeployTitle" + :title="$options.i18n.redeployTitle" :disabled="isRetrying" + :loading="isRetrying" class="js-pipelines-retry-button btn-retry" data-qa-selector="pipeline_retry_button" icon="repeat" @@ -350,10 +366,12 @@ export default { <gl-button v-if="pipeline.flags.cancelable" + v-gl-tooltip.hover + v-gl-modal-directive="'confirmation-modal'" + :aria-label="$options.i18n.cancelTitle" + :title="$options.i18n.cancelTitle" :loading="isCancelling" :disabled="isCancelling" - data-toggle="modal" - data-target="#confirmation-modal" icon="close" variant="danger" category="primary" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index 7d13ee582c6..dd09247337c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -1,12 +1,11 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import '~/lib/utils/datetime_utility'; -import tooltip from '~/vue_shared/directives/tooltip'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlIcon }, mixins: [timeagoMixin], @@ -51,7 +50,7 @@ export default { }; </script> <template> - <div class="table-section section-15 pipelines-time-ago"> + <div class="table-section section-15"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Duration') }}</div> <div class="table-mobile-content"> <p v-if="hasDuration" class="duration"> @@ -59,11 +58,11 @@ export default { {{ durationFormatted }} </p> - <p v-if="hasFinishedTime" class="finished-at d-none d-sm-none d-md-block"> + <p v-if="hasFinishedTime" class="finished-at d-none d-md-block"> <gl-icon name="calendar" class="gl-vertical-align-baseline!" aria-hidden="true" /> <time - v-tooltip + v-gl-tooltip :title="tooltipTitle(finishedTime)" data-placement="top" data-container="body" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue index dfa6d8c13a5..ae5758233bc 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue @@ -3,7 +3,7 @@ import { GlFilteredSearchToken, GlAvatar, GlFilteredSearchSuggestion, - GlDeprecatedDropdownDivider, + GlDropdownDivider, GlLoadingIcon, } from '@gitlab/ui'; import { debounce } from 'lodash'; @@ -21,7 +21,7 @@ export default { GlFilteredSearchToken, GlAvatar, GlFilteredSearchSuggestion, - GlDeprecatedDropdownDivider, + GlDropdownDivider, GlLoadingIcon, }, props: { @@ -94,7 +94,7 @@ export default { <gl-filtered-search-suggestion :value="$options.anyTriggerAuthor">{{ $options.anyTriggerAuthor }}</gl-filtered-search-suggestion> - <gl-deprecated-dropdown-divider /> + <gl-dropdown-divider /> <gl-loading-icon v-if="loading" /> <template v-else> 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 aa53c5040e8..2b92ffc3f26 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,6 +1,6 @@ <script> import { mapGetters } from 'vuex'; -import { GlTooltipDirective, GlFriendlyWrap, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlFriendlyWrap, GlIcon, GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; export default { @@ -8,6 +8,7 @@ export default { components: { GlIcon, GlFriendlyWrap, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -45,6 +46,9 @@ export default { <div role="rowheader" class="table-section section-20"> {{ __('Name') }} </div> + <div role="rowheader" class="table-section section-10"> + {{ __('Filename') }} + </div> <div role="rowheader" class="table-section section-10 text-center"> {{ __('Status') }} </div> @@ -63,18 +67,30 @@ export default { > <div class="table-section section-20 section-wrap"> <div role="rowheader" class="table-mobile-header">{{ __('Suite') }}</div> - <div class="table-mobile-content pr-md-1 gl-overflow-wrap-break"> + <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break"> <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="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 gl-overflow-wrap-break"> - <gl-friendly-wrap - data-testid="caseName" - :symbols="$options.wrapSymbols" - :text="testCase.name" + <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break"> + <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.name" /> + </div> + </div> + + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Filename') }}</div> + <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break"> + <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.file" /> + <gl-button + v-gl-tooltip + size="small" + category="tertiary" + icon="copy-to-clipboard" + :title="__('Copy to clipboard')" + :data-clipboard-text="testCase.file" + :aria-label="__('Copy to clipboard')" /> </div> </div> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index abe5e1060c8..607e7a66f44 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -13,6 +13,8 @@ export const TestStatus = { FAILED: 'failed', SKIPPED: 'skipped', SUCCESS: 'success', + ERROR: 'error', + UNKNOWN: 'unknown', }; export const FETCH_AUTHOR_ERROR_MESSAGE = __('There was a problem fetching project users.'); @@ -21,3 +23,12 @@ export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project export const RAW_TEXT_WARNING = s__( 'Pipeline|Raw text search is not currently supported. Please use the available search tokens.', ); + +/* Error constants shared across graphs */ +export const DEFAULT = 'default'; +export const DELETE_FAILURE = 'delete_pipeline_failure'; +export const DRAW_FAILURE = 'draw_failure'; +export const LOAD_FAILURE = 'load_failure'; +export const PARSE_FAILURE = 'parse_failure'; +export const POST_FAILURE = 'post_failure'; +export const UNSUPPORTED_DATA = 'unsupported_data'; diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql new file mode 100644 index 00000000000..06083daeca0 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql @@ -0,0 +1,30 @@ +query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { + project(fullPath: $fullPath) { + pipeline(iid: $iid) { + id + status + retryable + cancelable + userPermissions { + destroyPipeline + } + detailedStatus { + detailsPath + icon + group + text + } + createdAt + user { + name + webPath + email + avatarUrl + status { + message + emoji + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js deleted file mode 100644 index 53b7a174517..00000000000 --- a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js +++ /dev/null @@ -1,54 +0,0 @@ -import { escape } from 'lodash'; - -export default { - props: { - isLoading: { - type: Boolean, - required: true, - }, - pipeline: { - type: Object, - required: true, - }, - }, - computed: { - graph() { - return this.pipeline.details && this.pipeline.details.stages; - }, - }, - methods: { - capitalizeStageName(name) { - const escapedName = escape(name); - return escapedName.charAt(0).toUpperCase() + escapedName.slice(1); - }, - isFirstColumn(index) { - return index === 0; - }, - stageConnectorClass(index, stage) { - let className; - - // If it's the first stage column and only has one job - if (index === 0 && stage.groups.length === 1) { - className = 'no-margin'; - } else if (index > 0) { - // If it is not the first column - className = 'left-margin'; - } - - return className; - }, - refreshPipelineGraph() { - this.$emit('refreshPipelineGraph'); - }, - /** - * CSS class is applied: - * - if pipeline graph contains only one stage column component - * - * @param {number} index - * @returns {boolean} - */ - shouldAddRightMargin(index) { - return !(index === this.graph.length - 1); - }, - }, -}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 745f5b886a5..67aec12655a 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -7,10 +7,11 @@ import pipelineGraph from './components/graph/graph_component.vue'; import createDagApp from './pipeline_details_dag'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import PipelinesMediator from './pipeline_details_mediator'; -import pipelineHeader from './components/header_component.vue'; +import legacyPipelineHeader from './components/legacy_header_component.vue'; import eventHub from './event_hub'; import TestReports from './components/test_reports/test_reports.vue'; import createTestReportsStore from './stores/test_reports'; +import { createPipelineHeaderApp } from './pipeline_details_header'; Vue.use(Translate); @@ -56,7 +57,7 @@ const createPipelinesDetailApp = mediator => { }); }; -const createPipelineHeaderApp = mediator => { +const createLegacyPipelineHeaderApp = mediator => { if (!document.querySelector(SELECTORS.PIPELINE_HEADER)) { return; } @@ -64,7 +65,7 @@ const createPipelineHeaderApp = mediator => { new Vue({ el: SELECTORS.PIPELINE_HEADER, components: { - pipelineHeader, + legacyPipelineHeader, }, data() { return { @@ -95,7 +96,7 @@ const createPipelineHeaderApp = mediator => { }, }, render(createElement) { - return createElement('pipeline-header', { + return createElement('legacy-pipeline-header', { props: { isLoading: this.mediator.state.isLoading, pipeline: this.mediator.store.state.pipeline, @@ -132,7 +133,12 @@ export default () => { mediator.fetchPipeline(); createPipelinesDetailApp(mediator); - createPipelineHeaderApp(mediator); + + if (gon.features.graphqlPipelineHeader) { + createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER); + } else { + createLegacyPipelineHeaderApp(mediator); + } createTestDetails(); createDagApp(); }; diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js new file mode 100644 index 00000000000..27fe9ba3f19 --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_header.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import pipelineHeader from './components/header_component.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const createPipelineHeaderApp = elSelector => { + const el = document.querySelector(elSelector); + + if (!el) { + return; + } + + const { cancelPath, deletePath, fullPath, pipelineId, pipelineIid, retryPath } = el?.dataset; + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + pipelineHeader, + }, + apolloProvider, + provide: { + paths: { + cancel: cancelPath, + delete: deletePath, + fullProject: fullPath, + retry: retryPath, + }, + pipelineId, + pipelineIid, + }, + render(createElement) { + return createElement('pipeline-header', {}); + }, + }); +}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js index 8f1ac305cda..42406e5a67a 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/utils.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js @@ -1,13 +1,19 @@ import { __, sprintf } from '../../../locale'; +import { TestStatus } from '../../constants'; export function iconForTestStatus(status) { switch (status) { - case 'success': + case TestStatus.SUCCESS: return 'status_success_borderless'; - case 'failed': + case TestStatus.FAILED: return 'status_failed_borderless'; - default: + case TestStatus.ERROR: + return 'status_warning_borderless'; + case TestStatus.SKIPPED: return 'status_skipped_borderless'; + case TestStatus.UNKNOWN: + default: + return 'status_notfound_borderless'; } } diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index bd53b22784c..7d1a1762e0d 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -5,6 +5,8 @@ export const validateParams = params => { return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val); }; +export const createUniqueJobId = (stageName, jobName) => `${stageName}-${jobName}`; + /** * This function takes a json payload that comes from a yml * file converted to json through `jsyaml` library. Because we @@ -18,6 +20,16 @@ export const validateParams = params => { export const preparePipelineGraphData = jsonData => { const jsonKeys = Object.keys(jsonData); const jobNames = jsonKeys.filter(job => jsonData[job]?.stage); + // Creates an object with only the valid jobs + const jobs = jsonKeys.reduce((acc, val) => { + if (jobNames.includes(val)) { + return { + ...acc, + [val]: { ...jsonData[val], id: createUniqueJobId(jsonData[val].stage, val) }, + }; + } + return { ...acc }; + }, {}); // We merge both the stages from the "stages" key in the yaml and the stage associated // with each job to show the user both the stages they explicitly defined, and those @@ -40,10 +52,45 @@ export const preparePipelineGraphData = jsonData => { return { name: stage, groups: stageJobs.map(job => { - return { name: job, jobs: [{ ...jsonData[job] }] }; + return { + name: job, + jobs: [{ ...jsonData[job] }], + id: createUniqueJobId(stage, job), + }; }), }; }); - return { stages: pipelineData }; + return { stages: pipelineData, jobs }; +}; + +export const generateJobNeedsDict = ({ jobs }) => { + const arrOfJobNames = Object.keys(jobs); + + return arrOfJobNames.reduce((acc, value) => { + const recursiveNeeds = jobName => { + if (!jobs[jobName]?.needs) { + return []; + } + + return jobs[jobName].needs + .map(job => { + const { id } = jobs[job]; + // If we already have the needs of a job in the accumulator, + // then we use the memoized data instead of the recursive call + // to save some performance. + const newNeeds = acc[id] ?? recursiveNeeds(job); + + return [id, ...newNeeds]; + }) + .flat(Infinity); + }; + + // To ensure we don't have duplicates job relationship when 2 jobs + // needed by another both depends on the same jobs, we remove any + // duplicates from the array. + const uniqueValues = Array.from(new Set(recursiveNeeds(value))); + + return { ...acc, [jobs[value].id]: uniqueValues }; + }, {}); }; diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index 4aaa2cff2ac..200e5ba255f 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -1,6 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ import { escape } from 'lodash'; +import { GlButton } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { s__, sprintf } from '~/locale'; @@ -9,6 +10,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; export default { components: { GlModal: DeprecatedModal2, + GlButton, }, props: { actionUrl: { @@ -100,15 +102,15 @@ Please update your Git repository remotes as soon as possible.`), </div> <p class="form-text text-muted">{{ path }}</p> </div> - <button + <gl-button :data-target="`#${$options.modalId}`" :disabled="isRequestPending || newUsername === username" - class="btn btn-warning" - type="button" + category="primary" + variant="warning" data-toggle="modal" > {{ $options.buttonText }} - </button> + </gl-button> <gl-modal :id="$options.modalId" :header-title-text="s__('Profiles|Change username') + '?'" diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js index 55bc9fb8955..ecb69422287 100644 --- a/app/assets/javascripts/profile/gl_crop.js +++ b/app/assets/javascripts/profile/gl_crop.js @@ -3,6 +3,7 @@ import $ from 'jquery'; import 'cropper'; import { isString } from 'lodash'; +import { loadCSSFile } from '../lib/utils/css_utils'; (() => { // Matches everything but the file name @@ -180,6 +181,9 @@ import { isString } from 'lodash'; } } + const cropModal = document.querySelector('.modal-profile-crop'); + if (cropModal) loadCSSFile(cropModal.dataset.cropperCssPath); + $.fn.glCrop = function(opts) { return this.each(function() { return $(this).data('glcrop', new GitLabCrop(this, opts)); diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 6822fa8f7c7..4755a4aa9ba 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; +import { Rails } from '~/lib/utils/rails_ujs'; import { deprecatedCreateFlash as flash } from '../flash'; import { parseBoolean } from '~/lib/utils/common_utils'; import TimezoneDropdown, { @@ -48,9 +49,13 @@ export default class Profile { } submitForm() { - return $(this) - .parents('form') - .submit(); + const $form = $(this).parents('form'); + + if ($form.data('remote')) { + Rails.fire($form[0], 'submit'); + } else { + $form.submit(); + } } onSubmitForm(e) { diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index 70fce4a4d09..2f35c4485f9 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -2,9 +2,10 @@ import $ from 'jquery'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import { sanitize } from 'dompurify'; +import { sanitize } from '~/lib/dompurify'; import axios from '~/lib/utils/axios_utils'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; +import { spriteIcon } from '~/lib/utils/common_utils'; import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; @@ -125,7 +126,10 @@ export default class ProjectFindFile { // make tbody row html static makeHtml(filePath, matches, blobItemUrl) { const $tr = $( - "<tr class='tree-item'><td class='tree-item-file-name link-container'><a><i class='fa fa-file-text-o fa-fw'></i><span class='str-truncated'></span></a></td></tr>", + `<tr class='tree-item'><td class='tree-item-file-name link-container'><a>${spriteIcon( + 'doc-text', + 's16 vertical-align-middle gl-mr-1', + )}<span class='str-truncated'></span></a></td></tr>`, ); if (matches) { $tr diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/project_label_subscription.js index 12c77b09b64..4fefc2ed569 100644 --- a/app/assets/javascripts/project_label_subscription.js +++ b/app/assets/javascripts/project_label_subscription.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import { __ } from './locale'; import axios from './lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from './flash'; +import { fixTitle } from '~/tooltips'; const tooltipTitles = { group: { @@ -66,6 +67,7 @@ export default class ProjectLabelSubscription { const type = /group/.test(originalTitle) ? 'group' : 'project'; const newTitle = tooltipTitles[type][newStatus]; - $button.attr('title', newTitle).tooltip('_fixTitle'); + $button.attr('title', newTitle); + fixTitle($button); } } diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js new file mode 100644 index 00000000000..352ac39f3c4 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/index.js @@ -0,0 +1,18 @@ +import { loadBranches } from './load_branches'; +import { fetchCommitMergeRequests } from '~/commit_merge_requests'; +import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; + +export const initCommitBoxInfo = (containerSelector = '.js-commit-box-info') => { + const containerEl = document.querySelector(containerSelector); + + // Display commit related branches + loadBranches(containerEl); + + // Related merge requests to this commit + fetchCommitMergeRequests(); + + // Display pipeline info for this commit + new MiniPipelineGraph({ + container: '.js-commit-pipeline-graph', + }).bindEvents(); +}; diff --git a/app/assets/javascripts/projects/commit_box/info/load_branches.js b/app/assets/javascripts/projects/commit_box/info/load_branches.js new file mode 100644 index 00000000000..0efa1998507 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/load_branches.js @@ -0,0 +1,20 @@ +import axios from 'axios'; +import { sanitize } from '~/lib/dompurify'; +import { __ } from '~/locale'; + +export const loadBranches = containerEl => { + if (!containerEl) { + return; + } + + const { commitPath } = containerEl.dataset; + const branchesEl = containerEl.querySelector('.commit-info.branches'); + axios + .get(commitPath) + .then(({ data }) => { + branchesEl.innerHTML = sanitize(data); + }) + .catch(() => { + branchesEl.textContent = __('Failed to load branches. Please try again.'); + }); +}; diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue index 2204ec3cbe7..3bc772fe60a 100644 --- a/app/assets/javascripts/projects/commits/components/author_select.vue +++ b/app/assets/javascripts/projects/commits/components/author_select.vue @@ -119,7 +119,6 @@ export default { <gl-dropdown-divider /> <gl-search-box-by-type v-model.trim="authorInput" - class="gl-m-3" :placeholder="__('Search')" @input="searchAuthors" /> diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index 927501748a5..157e2409f7f 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/wrapper'; import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index 2d321ead33e..a6019e9c01b 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -57,6 +57,10 @@ export default { text: s__('ProjectTemplates|Static Site Editor/Middleman'), icon: '.template-option .icon-sse_middleman', }, + gitpod_spring_petclinic: { + text: s__('ProjectTemplates|Gitpod/Spring Petclinic'), + icon: '.template-option .icon-gitpod_spring_petclinic', + }, nfhugo: { text: s__('ProjectTemplates|Netlify/Hugo'), icon: '.template-option .icon-nfhugo', diff --git a/app/assets/javascripts/projects/default_sample_data_templates.js b/app/assets/javascripts/projects/default_sample_data_templates.js new file mode 100644 index 00000000000..7c45e7ac62f --- /dev/null +++ b/app/assets/javascripts/projects/default_sample_data_templates.js @@ -0,0 +1,12 @@ +import { s__ } from '~/locale'; + +export default { + basic: { + text: s__('ProjectTemplates|Basic'), + icon: '.template-option .icon-basic', + }, + serenity_valley: { + text: s__('ProjectTemplates|Serenity Valley'), + icon: '.template-option .icon-serenity_valley', + }, +}; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 599aa52831b..d74a2d06786 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates'; +import DEFAULT_SAMPLE_DATA_TEMPLATES from '~/projects/default_sample_data_templates'; import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils'; import { convertToTitleCase, @@ -146,7 +147,8 @@ const bindEvents = () => { $selectedIcon.empty(); const value = $(this).val(); - const selectedTemplate = DEFAULT_PROJECT_TEMPLATES[value]; + const selectedTemplate = + DEFAULT_PROJECT_TEMPLATES[value] || DEFAULT_SAMPLE_DATA_TEMPLATES[value]; $selectedTemplateText.text(selectedTemplate.text); $(selectedTemplate.icon) .clone() diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js index 5d51b7ea57b..3ca5bca4bf2 100644 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -1,9 +1,9 @@ /* eslint-disable no-underscore-dangle, class-methods-use-this */ import { escape, find, countBy } from 'lodash'; import axios from '~/lib/utils/axios_utils'; -import { deprecatedCreateFlash as Flash } from '~/flash'; -import { n__, s__, __ } from '~/locale'; -import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVEL_NONE } from './constants'; +import createFlash from '~/flash'; +import { n__, s__, __, sprintf } from '~/locale'; +import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; export default class AccessDropdown { @@ -11,6 +11,7 @@ export default class AccessDropdown { const { $dropdown, accessLevel, accessLevelsData, hasLicense = true } = options; this.options = options; this.hasLicense = hasLicense; + this.deployKeysOnProtectedBranchesEnabled = gon.features.deployKeysOnProtectedBranches; this.groups = []; this.accessLevel = accessLevel; this.accessLevelsData = accessLevelsData.roles; @@ -18,6 +19,7 @@ export default class AccessDropdown { this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`); this.usersPath = '/-/autocomplete/users.json'; this.groupsPath = '/-/autocomplete/project_groups.json'; + this.deployKeysPath = '/-/autocomplete/deploy_keys_with_owners.json'; this.defaultLabel = this.$dropdown.data('defaultLabel'); this.setSelectedItems([]); @@ -146,6 +148,8 @@ export default class AccessDropdown { obj.access_level = item.access_level; } else if (item.type === LEVEL_TYPES.USER) { obj.user_id = item.user_id; + } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) { + obj.deploy_key_id = item.deploy_key_id; } else if (item.type === LEVEL_TYPES.GROUP) { obj.group_id = item.group_id; } @@ -177,6 +181,9 @@ export default class AccessDropdown { case LEVEL_TYPES.GROUP: comparator = LEVEL_ID_PROP.GROUP; break; + case LEVEL_TYPES.DEPLOY_KEY: + comparator = LEVEL_ID_PROP.DEPLOY_KEY; + break; case LEVEL_TYPES.USER: comparator = LEVEL_ID_PROP.USER; break; @@ -218,6 +225,11 @@ export default class AccessDropdown { group_id: selectedItem.id, type: LEVEL_TYPES.GROUP, }; + } else if (selectedItem.type === LEVEL_TYPES.DEPLOY_KEY) { + itemToAdd = { + deploy_key_id: selectedItem.id, + type: LEVEL_TYPES.DEPLOY_KEY, + }; } this.items.push(itemToAdd); @@ -233,11 +245,12 @@ export default class AccessDropdown { return true; } - if (item.type === LEVEL_TYPES.USER && item.user_id === itemToDelete.id) { - index = i; - } else if (item.type === LEVEL_TYPES.ROLE && item.access_level === itemToDelete.id) { - index = i; - } else if (item.type === LEVEL_TYPES.GROUP && item.group_id === itemToDelete.id) { + if ( + (item.type === LEVEL_TYPES.USER && item.user_id === itemToDelete.id) || + (item.type === LEVEL_TYPES.ROLE && item.access_level === itemToDelete.id) || + (item.type === LEVEL_TYPES.DEPLOY_KEY && item.deploy_key_id === itemToDelete.id) || + (item.type === LEVEL_TYPES.GROUP && item.group_id === itemToDelete.id) + ) { index = i; } @@ -289,6 +302,10 @@ export default class AccessDropdown { labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER])); } + if (counts[LEVEL_TYPES.DEPLOY_KEY] > 0) { + labelPieces.push(n__('1 deploy key', '%d deploy keys', counts[LEVEL_TYPES.DEPLOY_KEY])); + } + if (counts[LEVEL_TYPES.GROUP] > 0) { labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP])); } @@ -299,20 +316,31 @@ export default class AccessDropdown { getData(query, callback) { if (this.hasLicense) { Promise.all([ + this.getDeployKeys(query), this.getUsers(query), this.groupsData ? Promise.resolve(this.groupsData) : this.getGroups(), ]) - .then(([usersResponse, groupsResponse]) => { + .then(([deployKeysResponse, usersResponse, groupsResponse]) => { this.groupsData = groupsResponse; - callback(this.consolidateData(usersResponse.data, groupsResponse.data)); + callback( + this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data), + ); }) - .catch(() => Flash(__('Failed to load groups & users.'))); + .catch(() => { + if (this.deployKeysOnProtectedBranchesEnabled) { + createFlash({ message: __('Failed to load groups, users and deploy keys.') }); + } else { + createFlash({ message: __('Failed to load groups & users.') }); + } + }); } else { - callback(this.consolidateData()); + this.getDeployKeys(query) + .then(deployKeysResponse => callback(this.consolidateData(deployKeysResponse.data))) + .catch(() => createFlash({ message: __('Failed to load deploy keys.') })); } } - consolidateData(usersResponse = [], groupsResponse = []) { + consolidateData(deployKeysResponse, usersResponse = [], groupsResponse = []) { let consolidatedData = []; // ID property is handled differently locally from the server @@ -328,6 +356,10 @@ export default class AccessDropdown { // For Users // In dropdown: `id` // For submit: `user_id` + // + // For Deploy Keys + // In dropdown: `id` + // For submit: `deploy_key_id` /* * Build roles @@ -410,6 +442,38 @@ export default class AccessDropdown { } } + if (this.deployKeysOnProtectedBranchesEnabled) { + const deployKeys = deployKeysResponse.map(response => { + const { + id, + fingerprint, + title, + owner: { avatar_url, name, username }, + } = response; + + const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`; + + return { + id, + title: title.concat(' ', shortFingerprint), + avatar_url, + fullname: name, + username, + type: LEVEL_TYPES.DEPLOY_KEY, + }; + }); + + if (this.accessLevel === ACCESS_LEVELS.PUSH) { + if (deployKeys.length) { + consolidatedData = consolidatedData.concat( + [{ type: 'divider' }], + [{ type: 'header', content: s__('AccessDropdown|Deploy Keys') }], + deployKeys, + ); + } + } + } + return consolidatedData; } @@ -433,6 +497,22 @@ export default class AccessDropdown { }); } + getDeployKeys(query) { + if (this.deployKeysOnProtectedBranchesEnabled) { + return axios.get(this.buildUrl(gon.relative_url_root, this.deployKeysPath), { + params: { + search: query, + per_page: 20, + active: true, + project_id: gon.current_project_id, + push_code: true, + }, + }); + } + + return Promise.resolve({ data: [] }); + } + buildUrl(urlRoot, url) { let newUrl; if (urlRoot != null) { @@ -454,6 +534,9 @@ export default class AccessDropdown { case LEVEL_TYPES.ROLE: criteria = { access_level: item.id }; break; + case LEVEL_TYPES.DEPLOY_KEY: + criteria = { deploy_key_id: item.id }; + break; case LEVEL_TYPES.GROUP: criteria = { group_id: item.id }; break; @@ -470,6 +553,10 @@ export default class AccessDropdown { case LEVEL_TYPES.ROLE: groupRowEl = this.roleRowHtml(item, isActive); break; + case LEVEL_TYPES.DEPLOY_KEY: + groupRowEl = + this.accessLevel === ACCESS_LEVELS.PUSH ? this.deployKeyRowHtml(item, isActive) : ''; + break; case LEVEL_TYPES.GROUP: groupRowEl = this.groupRowHtml(item, isActive); break; @@ -495,6 +582,31 @@ export default class AccessDropdown { `; } + deployKeyRowHtml(key, isActive) { + const isActiveClass = isActive || ''; + + return ` + <li> + <a href="#" class="${isActiveClass}"> + <strong>${key.title}</strong> + <p> + ${sprintf( + __('Owned by %{image_tag}'), + { + image_tag: `<img src="${key.avatar_url}" class="avatar avatar-inline s26" width="30">`, + }, + false, + )} + <strong class="dropdown-menu-user-full-name gl-display-inline">${escape( + key.fullname, + )}</strong> + <span class="dropdown-menu-user-username gl-display-inline">${key.username}</span> + </p> + </a> + </li> + `; + } + groupRowHtml(group, isActive) { const isActiveClass = isActive || ''; const avatarEl = group.avatar_url diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js index fadb1f4f178..f5591c43dc4 100644 --- a/app/assets/javascripts/projects/settings/constants.js +++ b/app/assets/javascripts/projects/settings/constants.js @@ -1,13 +1,20 @@ export const LEVEL_TYPES = { ROLE: 'role', USER: 'user', + DEPLOY_KEY: 'deploy_key', GROUP: 'group', }; export const LEVEL_ID_PROP = { ROLE: 'access_level', USER: 'user_id', + DEPLOY_KEY: 'deploy_key_id', GROUP: 'group_id', }; +export const ACCESS_LEVELS = { + MERGE: 'merge_access_levels', + PUSH: 'push_access_levels', +}; + export const ACCESS_LEVEL_NONE = 0; diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index 81367f7d6b4..4bfed6d489d 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -1,6 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import ServiceDeskSetting from './service_desk_setting.vue'; import ServiceDeskService from '../services/service_desk_service'; import eventHub from '../event_hub'; @@ -122,11 +122,13 @@ export default { this.incomingEmail = data?.service_desk_address; this.showAlert(__('Changes were successfully made.'), 'success'); }) - .catch(() => + .catch(err => { this.showAlert( - __('An error occurred while saving the template. Please check if the template exists.'), - ), - ) + sprintf(__('An error occured while making the changes: %{error}'), { + error: err?.response?.data?.message, + }), + ); + }) .finally(() => { this.isTemplateSaving = false; }); diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index 6a0810ad3a1..e18cfefc3ca 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -1,22 +1,19 @@ <script> -import { GlButton, GlFormSelect, GlToggle, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlFormSelect, GlToggle, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import eventHub from '../event_hub'; export default { name: 'ServiceDeskSetting', - directives: { - tooltip, - }, components: { ClipboardButton, GlButton, GlFormSelect, GlToggle, GlLoadingIcon, + GlSprintf, }, mixins: [glFeatureFlagsMixin()], props: { @@ -60,6 +57,7 @@ export default { selectedTemplate: this.initialSelectedTemplate, outgoingName: this.initialOutgoingName || __('GitLab Support Bot'), projectKey: this.initialProjectKey, + baseEmail: this.incomingEmail.replace(this.initialProjectKey, ''), }; }, computed: { @@ -108,7 +106,7 @@ export default { <input ref="service-desk-incoming-email" type="text" - class="form-control incoming-email h-auto" + class="form-control incoming-email" :placeholder="__('Incoming email')" :aria-label="__('Incoming email')" aria-describedby="incoming-email-describer" @@ -119,16 +117,37 @@ export default { <clipboard-button :title="__('Copy')" :text="incomingEmail" - css-class="btn qa-clipboard-button" + css-class="input-group-text qa-clipboard-button" /> </div> </div> + <span v-if="projectKey" class="form-text text-muted"> + <gl-sprintf :message="__('Emails sent to %{email} will still be supported')"> + <template #email> + <code>{{ baseEmail }}</code> + </template> + </gl-sprintf> + </span> </template> <template v-else> <gl-loading-icon :inline="true" /> <span class="sr-only">{{ __('Fetching incoming email') }}</span> </template> + <template v-if="hasProjectKeySupport"> + <label for="service-desk-project-suffix" class="mt-3"> + {{ __('Project name suffix') }} + </label> + <input id="service-desk-project-suffix" v-model.trim="projectKey" class="form-control" /> + <span class="form-text text-muted"> + {{ + __( + 'Project name suffix is a user-defined string which will be appended to the project path, and will form the Service Desk email address.', + ) + }} + </span> + </template> + <label for="service-desk-template-select" class="mt-3"> {{ __('Template to append to all Service Desk issues') }} </label> @@ -144,19 +163,6 @@ export default { <span class="form-text text-muted"> {{ __('Emails sent from Service Desk will have this name') }} </span> - <template v-if="hasProjectKeySupport"> - <label for="service-desk-project-suffix" class="mt-3"> - {{ __('Project name suffix') }} - </label> - <input id="service-desk-project-suffix" v-model.trim="projectKey" class="form-control" /> - <span class="form-text text-muted mb-3"> - {{ - __( - 'Project name suffix is a user-defined string which will be appended to the project path, and will form the Service Desk email address.', - ) - }} - </span> - </template> <div class="gl-display-flex gl-justify-content-end"> <gl-button variant="success" diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue index e691f675e59..e582d5c3e47 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue @@ -1,16 +1,15 @@ <script> import Visibility from 'visibilityjs'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import ciIcon from '~/vue_shared/components/ci_icon.vue'; import Poll from '~/lib/utils/poll'; import { deprecatedCreateFlash as Flash } from '~/flash'; import { __, s__, sprintf } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; import CommitPipelineService from '../services/commit_pipeline_service'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { ciIcon, @@ -97,7 +96,7 @@ export default { <gl-loading-icon v-if="isLoading" size="lg" label="Loading pipeline status" /> <a v-else :href="ciStatus.details_path"> <ci-icon - v-tooltip + v-gl-tooltip :title="statusTitle" :aria-label="statusTitle" :status="ciStatus" diff --git a/app/assets/javascripts/protected_branches/constants.js b/app/assets/javascripts/protected_branches/constants.js index a17ae6811b7..ae5eaa8e622 100644 --- a/app/assets/javascripts/protected_branches/constants.js +++ b/app/assets/javascripts/protected_branches/constants.js @@ -7,12 +7,14 @@ export const LEVEL_TYPES = { ROLE: 'role', USER: 'user', GROUP: 'group', + DEPLOY_KEY: 'deploy_key', }; export const LEVEL_ID_PROP = { ROLE: 'access_level', USER: 'user_id', GROUP: 'group_id', + DEPLOY_KEY: 'deploy_key_id', }; export const ACCESS_LEVEL_NONE = 0; diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index 5ccffe9700e..19f6666fd52 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -108,6 +108,10 @@ export default class ProtectedBranchCreate { levelAttributes.push({ group_id: item.group_id, }); + } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) { + levelAttributes.push({ + deploy_key_id: item.deploy_key_id, + }); } }); diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index 85b123530b5..0084450c9b0 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -139,7 +139,6 @@ export default { <gl-search-box-by-type ref="searchBox" v-model.trim="query" - class="gl-m-3" :placeholder="i18n.searchPlaceholder" @input="onSearchBoxInput" @keydown.enter.prevent="onSearchBoxEnter" diff --git a/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue b/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue new file mode 100644 index 00000000000..d13d815a59e --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue @@ -0,0 +1,38 @@ +<script> +import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui'; + +import { DELETE_ALERT_TITLE, DELETE_ALERT_LINK_TEXT } from '../../constants/index'; + +export default { + components: { + GlSprintf, + GlAlert, + GlLink, + }, + props: { + runCleanupPoliciesHelpPagePath: { type: String, required: false, default: '' }, + cleanupPoliciesHelpPagePath: { type: String, required: false, default: '' }, + }, + i18n: { + DELETE_ALERT_TITLE, + DELETE_ALERT_LINK_TEXT, + }, +}; +</script> + +<template> + <gl-alert variant="warning" :title="$options.i18n.DELETE_ALERT_TITLE" @dismiss="$emit('dismiss')"> + <gl-sprintf :message="$options.i18n.DELETE_ALERT_LINK_TEXT"> + <template #adminLink="{content}"> + <gl-link data-testid="run-link" :href="runCleanupPoliciesHelpPagePath" target="_blank">{{ + content + }}</gl-link> + </template> + <template #docLink="{content}"> + <gl-link data-testid="help-link" :href="cleanupPoliciesHelpPagePath" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue index 661213733ac..0f6297ca406 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue @@ -123,7 +123,7 @@ export default { v-if="tag.location" :title="tag.location" :text="tag.location" - css-class="btn-default btn-transparent btn-clipboard" + category="tertiary" /> <gl-icon @@ -171,7 +171,7 @@ export default { /> </template> - <template v-if="!invalidTag" #details_published> + <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> @@ -186,7 +186,7 @@ export default { </gl-sprintf> </details-row> </template> - <template v-if="!invalidTag" #details_manifest_digest> + <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> @@ -197,11 +197,12 @@ export default { v-if="tag.digest" :title="tag.digest" :text="tag.digest" - css-class="btn-default btn-transparent btn-clipboard gl-p-0" + category="tertiary" + size="small" /> </details-row> </template> - <template v-if="!invalidTag" #details_configuration_digest> + <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> @@ -212,7 +213,8 @@ export default { v-if="formattedRevision" :title="formattedRevision" :text="formattedRevision" - css-class="btn-default btn-transparent btn-clipboard gl-p-0" + category="tertiary" + size="small" /> </details-row> </template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue index 32bf27f1143..cfd787b3f52 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 @@ -10,6 +10,7 @@ import { LIST_DELETE_BUTTON_DISABLED, REMOVE_REPOSITORY_LABEL, ROW_SCHEDULED_FOR_DELETION, + CLEANUP_TIMED_OUT_ERROR_MESSAGE, } from '../../constants/index'; export default { @@ -34,7 +35,6 @@ export default { LIST_DELETE_BUTTON_DISABLED, REMOVE_REPOSITORY_LABEL, ROW_SCHEDULED_FOR_DELETION, - ASYNC_DELETE_IMAGE_ERROR_MESSAGE, }, computed: { encodedItem() { @@ -42,6 +42,7 @@ export default { name: this.item.path, tags_path: this.item.tags_path, id: this.item.id, + cleanup_policy_started_at: this.item.cleanup_policy_started_at, }); return window.btoa(params); }, @@ -55,6 +56,14 @@ export default { this.item.tags_count, ); }, + warningIconText() { + if (this.item.failedDelete) { + return ASYNC_DELETE_IMAGE_ERROR_MESSAGE; + } else if (this.item.cleanup_policy_started_at) { + return CLEANUP_TIMED_OUT_ERROR_MESSAGE; + } + return null; + }, }, }; </script> @@ -82,11 +91,12 @@ export default { :disabled="item.deleting" :text="item.location" :title="item.location" - css-class="btn-default btn-transparent btn-clipboard gl-text-gray-300" + category="tertiary" /> <gl-icon - v-if="item.failedDelete" - v-gl-tooltip="{ title: $options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE }" + v-if="warningIconText" + v-gl-tooltip="{ title: warningIconText }" + data-testid="warning-icon" name="warning" class="gl-text-orange-500" /> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue index 7be68e77def..c2bd01701df 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue @@ -1,5 +1,4 @@ <script> -import { GlSprintf, GlLink } from '@gitlab/ui'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import { n__, sprintf } from '~/locale'; @@ -15,8 +14,6 @@ import { export default { components: { - GlSprintf, - GlLink, TitleArea, MetadataItem, }, @@ -54,8 +51,6 @@ export default { }, i18n: { CONTAINER_REGISTRY_TITLE, - LIST_INTRO_TEXT, - EXPIRATION_POLICY_DISABLED_MESSAGE, }, computed: { imagesCountText() { @@ -83,52 +78,40 @@ export default { !this.expirationPolicyEnabled && this.imagesCount > 0 && !this.hideExpirationPolicyData ); }, + infoMessages() { + const base = [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }]; + return this.showExpirationPolicyTip + ? [ + ...base, + { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: this.expirationPolicyHelpPagePath }, + ] + : base; + }, }, }; </script> <template> - <div> - <title-area :title="$options.i18n.CONTAINER_REGISTRY_TITLE"> - <template #right-actions> - <slot name="commands"></slot> - </template> - <template #metadata_count> - <metadata-item - v-if="imagesCount" - data-testid="images-count" - icon="container-image" - :text="imagesCountText" - /> - </template> - <template #metadata_exp_policies> - <metadata-item - v-if="!hideExpirationPolicyData" - data-testid="expiration-policy" - icon="expire" - :text="expirationPolicyText" - size="xl" - /> - </template> - </title-area> - - <div data-testid="info-area"> - <p> - <span data-testid="default-intro"> - <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT"> - <template #docLink="{content}"> - <gl-link :href="helpPagePath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </span> - <span v-if="showExpirationPolicyTip" data-testid="expiration-disabled-message"> - <gl-sprintf :message="$options.i18n.EXPIRATION_POLICY_DISABLED_MESSAGE"> - <template #docLink="{content}"> - <gl-link :href="expirationPolicyHelpPagePath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </span> - </p> - </div> - </div> + <title-area :title="$options.i18n.CONTAINER_REGISTRY_TITLE" :info-messages="infoMessages"> + <template #right-actions> + <slot name="commands"></slot> + </template> + <template #metadata-count> + <metadata-item + v-if="imagesCount" + data-testid="images-count" + icon="container-image" + :text="imagesCountText" + /> + </template> + <template #metadata-exp-policies> + <metadata-item + v-if="!hideExpirationPolicyData" + data-testid="expiration-policy" + icon="expire" + :text="expirationPolicyText" + size="xl" + /> + </template> + </title-area> </template> diff --git a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js index 8af25ca6ecc..48a6a015461 100644 --- a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js +++ b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js @@ -9,3 +9,10 @@ export const EXPIRATION_POLICY_DISABLED_TEXT = s__( export const EXPIRATION_POLICY_DISABLED_MESSAGE = s__( 'ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}', ); +export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted'); +export const DELETE_ALERT_LINK_TEXT = s__( + 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}', +); +export const CLEANUP_TIMED_OUT_ERROR_MESSAGE = s__( + 'ContainerRegistry|Cleanup timed out before it could delete all tags', +); diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index b697bca6259..d2fb695dbfa 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -4,6 +4,7 @@ import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import Tracking from '~/tracking'; import DeleteAlert from '../components/details_page/delete_alert.vue'; +import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue'; import DeleteModal from '../components/details_page/delete_modal.vue'; import DetailsHeader from '../components/details_page/details_header.vue'; import TagsList from '../components/details_page/tags_list.vue'; @@ -21,6 +22,7 @@ import { export default { components: { DeleteAlert, + PartialCleanupAlert, DetailsHeader, GlPagination, DeleteModal, @@ -37,13 +39,16 @@ export default { itemsToBeDeleted: [], isDesktop: true, deleteAlertType: null, + dismissPartialCleanupWarning: false, }; }, computed: { ...mapState(['tagsPagination', 'isLoading', 'config', 'tags']), - imageName() { - const { name } = decodeAndParse(this.$route.params.id); - return name; + queryParameters() { + return decodeAndParse(this.$route.params.id); + }, + showPartialCleanupWarning() { + return this.queryParameters.cleanup_policy_started_at && !this.dismissPartialCleanupWarning; }, tracking() { return { @@ -120,7 +125,14 @@ export default { class="gl-my-2" /> - <details-header :image-name="imageName" /> + <partial-cleanup-alert + v-if="showPartialCleanupWarning" + :run-cleanup-policies-help-page-path="config.runCleanupPoliciesHelpPagePath" + :cleanup-policies-help-page-path="config.cleanupPoliciesHelpPagePath" + @dismiss="dismissPartialCleanupWarning = true" + /> + + <details-header :image-name="queryParameters.name" /> <tags-loader v-if="isLoading" /> <template v-else> diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue index 2ee7bbef4c6..264d39a406a 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -1,7 +1,7 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; - +import { isEqual, get } from 'lodash'; +import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.graphql'; import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants'; import SettingsForm from './settings_form.vue'; @@ -19,21 +19,39 @@ export default { GlSprintf, GlLink, }, + inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries'], i18n: { UNAVAILABLE_FEATURE_TITLE, UNAVAILABLE_FEATURE_INTRO_TEXT, FETCH_SETTINGS_ERROR_MESSAGE, }, + apollo: { + containerExpirationPolicy: { + query: expirationPolicyQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update: data => data.project?.containerExpirationPolicy, + result({ data }) { + this.workingCopy = { ...get(data, 'project.containerExpirationPolicy', {}) }; + }, + error(e) { + this.fetchSettingsError = e; + }, + }, + }, data() { return { fetchSettingsError: false, + containerExpirationPolicy: null, + workingCopy: {}, }; }, computed: { - ...mapState(['isAdmin', 'adminSettingsPath']), - ...mapGetters({ isDisabled: 'getIsDisabled' }), - showSettingForm() { - return !this.isDisabled && !this.fetchSettingsError; + isDisabled() { + return !(this.containerExpirationPolicy || this.enableHistoricEntries); }, showDisabledFormMessage() { return this.isDisabled && !this.fetchSettingsError; @@ -41,21 +59,27 @@ export default { unavailableFeatureMessage() { return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT; }, - }, - mounted() { - this.fetchSettings().catch(() => { - this.fetchSettingsError = true; - }); + isEdited() { + return !isEqual(this.containerExpirationPolicy, this.workingCopy); + }, }, methods: { - ...mapActions(['fetchSettings']), + restoreOriginal() { + this.workingCopy = { ...this.containerExpirationPolicy }; + }, }, }; </script> <template> <div> - <settings-form v-if="showSettingForm" /> + <settings-form + v-if="!isDisabled" + v-model="workingCopy" + :is-loading="$apollo.queries.containerExpirationPolicy.loading" + :is-edited="isEdited" + @reset="restoreOriginal" + /> <template v-else> <gl-alert v-if="showDisabledFormMessage" diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index 7a26fb5cbee..a9b35d4e29f 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -1,28 +1,45 @@ <script> -import { get } from 'lodash'; -import { mapActions, mapState, mapGetters } from 'vuex'; -import { GlCard, GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlCard, GlButton } from '@gitlab/ui'; import Tracking from '~/tracking'; -import { mapComputed } from '~/vuex_shared/bindings'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, } from '../../shared/constants'; import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue'; import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants'; +import { formOptionsGenerator } from '~/registry/shared/utils'; +import updateContainerExpirationPolicyMutation from '../graphql/mutations/update_container_expiration_policy.graphql'; +import { updateContainerExpirationPolicy } from '../graphql/utils/cache_update'; export default { components: { GlCard, GlButton, - GlLoadingIcon, ExpirationPolicyFields, }, mixins: [Tracking.mixin()], + inject: ['projectPath'], + props: { + value: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + isEdited: { + type: Boolean, + required: false, + default: false, + }, + }, labelsConfig: { cols: 3, align: 'right', }, + formOptions: formOptionsGenerator(), i18n: { CLEANUP_POLICY_CARD_HEADER, SET_CLEANUP_POLICY_BUTTON, @@ -34,49 +51,85 @@ export default { }, fieldsAreValid: true, apiErrors: null, + mutationLoading: false, }; }, computed: { - ...mapState(['formOptions', 'isLoading']), - ...mapGetters({ isEdited: 'getIsEdited' }), - ...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'), + prefilledForm() { + return { + ...this.value, + cadence: this.findDefaultOption('cadence'), + keepN: this.findDefaultOption('keepN'), + olderThan: this.findDefaultOption('olderThan'), + }; + }, + showLoadingIcon() { + return this.isLoading || this.mutationLoading; + }, isSubmitButtonDisabled() { - return !this.fieldsAreValid || this.isLoading; + return !this.fieldsAreValid || this.showLoadingIcon; }, isCancelButtonDisabled() { - return !this.isEdited || this.isLoading; + return !this.isEdited || this.isLoading || this.mutationLoading; + }, + mutationVariables() { + return { + projectPath: this.projectPath, + enabled: this.value.enabled, + cadence: this.value.cadence, + olderThan: this.value.olderThan, + keepN: this.value.keepN, + nameRegex: this.value.nameRegex, + nameRegexKeep: this.value.nameRegexKeep, + }; }, }, methods: { - ...mapActions(['resetSettings', 'saveSettings']), + findDefaultOption(option) { + return this.value[option] || this.$options.formOptions[option].find(f => f.default)?.key; + }, reset() { this.track('reset_form'); this.apiErrors = null; - this.resetSettings(); + this.$emit('reset'); }, setApiErrors(response) { - const messages = get(response, 'data.message', []); - - this.apiErrors = Object.keys(messages).reduce((acc, curr) => { - if (curr.startsWith('container_expiration_policy.')) { - const key = curr.replace('container_expiration_policy.', ''); - acc[key] = get(messages, [curr, 0], ''); - } + this.apiErrors = response.graphQLErrors.reduce((acc, curr) => { + curr.extensions.problems.forEach(item => { + acc[item.path[0]] = item.message; + }); return acc; }, {}); }, submit() { this.track('submit_form'); this.apiErrors = null; - this.saveSettings() - .then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' })) - .catch(({ response }) => { - this.setApiErrors(response); + this.mutationLoading = true; + return this.$apollo + .mutate({ + mutation: updateContainerExpirationPolicyMutation, + variables: { + input: this.mutationVariables, + }, + update: updateContainerExpirationPolicy(this.projectPath), + }) + .then(({ data }) => { + const errorMessage = data?.updateContainerExpirationPolicy?.errors[0]; + if (errorMessage) { + this.$toast.show(errorMessage, { type: 'error' }); + } + this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }); + }) + .catch(error => { + this.setApiErrors(error); this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' }); + }) + .finally(() => { + this.mutationLoading = false; }); }, onModelChange(changePayload) { - this.settings = changePayload.newValue; + this.$emit('input', changePayload.newValue); if (this.apiErrors) { this.apiErrors[changePayload.modified] = undefined; } @@ -93,8 +146,8 @@ export default { </template> <template #default> <expiration-policy-fields - :value="settings" - :form-options="formOptions" + :value="prefilledForm" + :form-options="$options.formOptions" :is-loading="isLoading" :api-errors="apiErrors" @validated="fieldsAreValid = true" @@ -103,27 +156,25 @@ export default { /> </template> <template #footer> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button - ref="cancel-button" - type="reset" - class="gl-mr-3 gl-display-block" - :disabled="isCancelButtonDisabled" - > - {{ __('Cancel') }} - </gl-button> - <gl-button - ref="save-button" - type="submit" - :disabled="isSubmitButtonDisabled" - variant="success" - category="primary" - class="gl-display-flex gl-justify-content-center gl-align-items-center js-no-auto-disable" - > - {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} - <gl-loading-icon v-if="isLoading" class="gl-ml-3" /> - </gl-button> - </div> + <gl-button + ref="cancel-button" + type="reset" + class="gl-mr-3 gl-display-block float-right" + :disabled="isCancelButtonDisabled" + > + {{ __('Cancel') }} + </gl-button> + <gl-button + ref="save-button" + type="submit" + :disabled="isSubmitButtonDisabled" + :loading="showLoadingIcon" + variant="success" + category="primary" + class="js-no-auto-disable" + > + {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} + </gl-button> </template> </gl-card> </form> diff --git a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql new file mode 100644 index 00000000000..224e0ed9472 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql @@ -0,0 +1,8 @@ +fragment ContainerExpirationPolicyFields on ContainerExpirationPolicy { + cadence + enabled + keepN + nameRegex + nameRegexKeep + olderThan +} diff --git a/app/assets/javascripts/registry/settings/graphql/index.js b/app/assets/javascripts/registry/settings/graphql/index.js new file mode 100644 index 00000000000..16152eb81f6 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +export const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), +}); diff --git a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql new file mode 100644 index 00000000000..c40cd115ab0 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql @@ -0,0 +1,10 @@ +#import "../fragments/container_expiration_policy.fragment.graphql" + +mutation updateContainerExpirationPolicy($input: UpdateContainerExpirationPolicyInput!) { + updateContainerExpirationPolicy(input: $input) { + containerExpirationPolicy { + ...ContainerExpirationPolicyFields + } + errors + } +} diff --git a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql new file mode 100644 index 00000000000..c171be0ad07 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql @@ -0,0 +1,9 @@ +#import "../fragments/container_expiration_policy.fragment.graphql" + +query getProjectExpirationPolicy($projectPath: ID!) { + project(fullPath: $projectPath) { + containerExpirationPolicy { + ...ContainerExpirationPolicyFields + } + } +} diff --git a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js new file mode 100644 index 00000000000..88067d52b51 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js @@ -0,0 +1,22 @@ +import { produce } from 'immer'; +import expirationPolicyQuery from '../queries/get_expiration_policy.graphql'; + +export const updateContainerExpirationPolicy = projectPath => (client, { data: updatedData }) => { + const queryAndParams = { + query: expirationPolicyQuery, + variables: { projectPath }, + }; + const sourceData = client.readQuery(queryAndParams); + + const data = produce(sourceData, draftState => { + // eslint-disable-next-line no-param-reassign + draftState.project.containerExpirationPolicy = { + ...updatedData.updateContainerExpirationPolicy.containerExpirationPolicy, + }; + }); + + client.writeQuery({ + ...queryAndParams, + data, + }); +}; diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js index a318aa2a694..f7b1c5abd3a 100644 --- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js +++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js @@ -1,8 +1,9 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; import Translate from '~/vue_shared/translate'; -import store from './store'; +import { parseBoolean } from '~/lib/utils/common_utils'; import RegistrySettingsApp from './components/registry_settings_app.vue'; +import { apolloProvider } from './graphql/index'; Vue.use(GlToast); Vue.use(Translate); @@ -12,13 +13,19 @@ export default () => { if (!el) { return null; } - store.dispatch('setInitialState', el.dataset); + const { projectPath, isAdmin, adminSettingsPath, enableHistoricEntries } = el.dataset; return new Vue({ el, - store, + apolloProvider, components: { RegistrySettingsApp, }, + provide: { + projectPath, + isAdmin: parseBoolean(isAdmin), + adminSettingsPath, + enableHistoricEntries: parseBoolean(enableHistoricEntries), + }, render(createElement) { return createElement('registry-settings-app', {}); }, diff --git a/app/assets/javascripts/registry/settings/store/actions.js b/app/assets/javascripts/registry/settings/store/actions.js deleted file mode 100644 index 0530a870ecc..00000000000 --- a/app/assets/javascripts/registry/settings/store/actions.js +++ /dev/null @@ -1,30 +0,0 @@ -import Api from '~/api'; -import * as types from './mutation_types'; - -export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); -export const updateSettings = ({ commit }, data) => commit(types.UPDATE_SETTINGS, data); -export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); -export const receiveSettingsSuccess = ({ commit }, data) => { - commit(types.SET_SETTINGS, data); -}; -export const resetSettings = ({ commit }) => commit(types.RESET_SETTINGS); - -export const fetchSettings = ({ dispatch, state }) => { - dispatch('toggleLoading'); - return Api.project(state.projectId) - .then(({ data: { container_expiration_policy } }) => - dispatch('receiveSettingsSuccess', container_expiration_policy), - ) - .finally(() => dispatch('toggleLoading')); -}; - -export const saveSettings = ({ dispatch, state }) => { - dispatch('toggleLoading'); - return Api.updateProject(state.projectId, { - container_expiration_policy_attributes: state.settings, - }) - .then(({ data: { container_expiration_policy } }) => - dispatch('receiveSettingsSuccess', container_expiration_policy), - ) - .finally(() => dispatch('toggleLoading')); -}; diff --git a/app/assets/javascripts/registry/settings/store/getters.js b/app/assets/javascripts/registry/settings/store/getters.js deleted file mode 100644 index ac1a931d8e0..00000000000 --- a/app/assets/javascripts/registry/settings/store/getters.js +++ /dev/null @@ -1,26 +0,0 @@ -import { isEqual } from 'lodash'; -import { findDefaultOption } from '../../shared/utils'; - -export const getCadence = state => - state.settings.cadence || findDefaultOption(state.formOptions.cadence); - -export const getKeepN = state => - state.settings.keep_n || findDefaultOption(state.formOptions.keepN); - -export const getOlderThan = state => - state.settings.older_than || findDefaultOption(state.formOptions.olderThan); - -export const getSettings = (state, getters) => ({ - enabled: state.settings.enabled, - cadence: getters.getCadence, - older_than: getters.getOlderThan, - keep_n: getters.getKeepN, - name_regex: state.settings.name_regex, - name_regex_keep: state.settings.name_regex_keep, -}); - -export const getIsEdited = state => !isEqual(state.original, state.settings); - -export const getIsDisabled = state => { - return !(state.original || state.enableHistoricEntries); -}; diff --git a/app/assets/javascripts/registry/settings/store/mutation_types.js b/app/assets/javascripts/registry/settings/store/mutation_types.js deleted file mode 100644 index db499ffa761..00000000000 --- a/app/assets/javascripts/registry/settings/store/mutation_types.js +++ /dev/null @@ -1,5 +0,0 @@ -export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; -export const UPDATE_SETTINGS = 'UPDATE_SETTINGS'; -export const TOGGLE_LOADING = 'TOGGLE_LOADING'; -export const SET_SETTINGS = 'SET_SETTINGS'; -export const RESET_SETTINGS = 'RESET_SETTINGS'; diff --git a/app/assets/javascripts/registry/settings/store/mutations.js b/app/assets/javascripts/registry/settings/store/mutations.js deleted file mode 100644 index 3ba13419b98..00000000000 --- a/app/assets/javascripts/registry/settings/store/mutations.js +++ /dev/null @@ -1,29 +0,0 @@ -import { parseBoolean } from '~/lib/utils/common_utils'; -import * as types from './mutation_types'; - -export default { - [types.SET_INITIAL_STATE](state, initialState) { - state.projectId = initialState.projectId; - state.formOptions = { - cadence: JSON.parse(initialState.cadenceOptions), - keepN: JSON.parse(initialState.keepNOptions), - olderThan: JSON.parse(initialState.olderThanOptions), - }; - state.enableHistoricEntries = parseBoolean(initialState.enableHistoricEntries); - state.isAdmin = parseBoolean(initialState.isAdmin); - state.adminSettingsPath = initialState.adminSettingsPath; - }, - [types.UPDATE_SETTINGS](state, data) { - state.settings = { ...state.settings, ...data.settings }; - }, - [types.SET_SETTINGS](state, settings) { - state.settings = settings ?? state.settings; - state.original = Object.freeze(settings); - }, - [types.RESET_SETTINGS](state) { - state.settings = { ...state.original }; - }, - [types.TOGGLE_LOADING](state) { - state.isLoading = !state.isLoading; - }, -}; diff --git a/app/assets/javascripts/registry/settings/store/state.js b/app/assets/javascripts/registry/settings/store/state.js deleted file mode 100644 index fccc0991c1c..00000000000 --- a/app/assets/javascripts/registry/settings/store/state.js +++ /dev/null @@ -1,42 +0,0 @@ -export default () => ({ - /* - * Project Id used to build the API call - */ - projectId: '', - /* - * Boolean to determine if the UI is loading data from the API - */ - isLoading: false, - /* - * Boolean to determine if the user is an admin - */ - isAdmin: false, - /* - * String containing the full path to the admin config page for CI/CD - */ - adminSettingsPath: '', - /* - * Boolean to determine if project created before 12.8 can use this feature - */ - enableHistoricEntries: false, - /* - * This contains the data shown and manipulated in the UI - * Has the following structure: - * { - * enabled: Boolean - * cadence: String, - * older_than: String, - * keep_n: String, - * name_regex: String - * } - */ - settings: {}, - /* - * Same structure as settings, above but Frozen object and used only in case the user clicks 'cancel', initialized to null - */ - original: null, - /* - * Contains the options used to populate the form selects - */ - formOptions: {}, -}); diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue index 1ff2f6f99e5..2b8e9f6ff64 100644 --- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue +++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue @@ -68,34 +68,31 @@ export default { { name: 'expiration-policy-interval', label: EXPIRATION_INTERVAL_LABEL, - model: 'older_than', - optionKey: 'olderThan', + model: 'olderThan', }, { name: 'expiration-policy-schedule', label: EXPIRATION_SCHEDULE_LABEL, model: 'cadence', - optionKey: 'cadence', }, { name: 'expiration-policy-latest', label: KEEP_N_LABEL, - model: 'keep_n', - optionKey: 'keepN', + model: 'keepN', }, ], textAreaList: [ { name: 'expiration-policy-name-matching', label: NAME_REGEX_LABEL, - model: 'name_regex', + model: 'nameRegex', placeholder: NAME_REGEX_PLACEHOLDER, description: NAME_REGEX_DESCRIPTION, }, { name: 'expiration-policy-keep-name', label: NAME_REGEX_KEEP_LABEL, - model: 'name_regex_keep', + model: 'nameRegexKeep', placeholder: NAME_REGEX_KEEP_PLACEHOLDER, description: NAME_REGEX_KEEP_DESCRIPTION, }, @@ -107,17 +104,16 @@ export default { }, computed: { ...mapComputedToEvent( - ['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex', 'name_regex_keep'], + ['enabled', 'cadence', 'olderThan', 'keepN', 'nameRegex', 'nameRegexKeep'], 'value', ), policyEnabledText() { return this.enabled ? ENABLED_TEXT : DISABLED_TEXT; }, textAreaValidation() { - const nameRegexErrors = - this.apiErrors?.name_regex || this.validateRegexLength(this.name_regex); + const nameRegexErrors = this.apiErrors?.nameRegex || this.validateRegexLength(this.nameRegex); const nameKeepRegexErrors = - this.apiErrors?.name_regex_keep || this.validateRegexLength(this.name_regex_keep); + this.apiErrors?.nameRegexKeep || this.validateRegexLength(this.nameRegexKeep); return { /* @@ -127,11 +123,11 @@ export default { * false: red border, error message * So in this function we keep null if the are no message otherwise we 'invert' the error message */ - name_regex: { + nameRegex: { state: nameRegexErrors === null ? null : !nameRegexErrors, message: nameRegexErrors, }, - name_regex_keep: { + nameRegexKeep: { state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors, message: nameKeepRegexErrors, }, @@ -139,8 +135,8 @@ export default { }, fieldsValidity() { return ( - this.textAreaValidation.name_regex.state !== false && - this.textAreaValidation.name_regex_keep.state !== false + this.textAreaValidation.nameRegex.state !== false && + this.textAreaValidation.nameRegexKeep.state !== false ); }, isFormElementDisabled() { @@ -216,11 +212,7 @@ export default { :disabled="isFormElementDisabled" @input="updateModel($event, select.model)" > - <option - v-for="option in formOptions[select.optionKey]" - :key="option.key" - :value="option.key" - > + <option v-for="option in formOptions[select.model]" :key="option.key" :value="option.key"> {{ option.label }} </option> </gl-form-select> diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js index 36d55c7610e..735d72972e6 100644 --- a/app/assets/javascripts/registry/shared/constants.js +++ b/app/assets/javascripts/registry/shared/constants.js @@ -43,3 +43,27 @@ export const NAME_REGEX_KEEP_PLACEHOLDER = ''; export const NAME_REGEX_KEEP_DESCRIPTION = s__( 'ContainerRegistry|Wildcards such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported', ); + +export const KEEP_N_OPTIONS = [ + { variable: 1, key: 'ONE_TAG', default: false }, + { variable: 5, key: 'FIVE_TAGS', default: false }, + { variable: 10, key: 'TEN_TAGS', default: true }, + { variable: 25, key: 'TWENTY_FIVE_TAGS', default: false }, + { variable: 50, key: 'FIFTY_TAGS', default: false }, + { variable: 100, key: 'ONE_HUNDRED_TAGS', default: false }, +]; + +export const CADENCE_OPTIONS = [ + { key: 'EVERY_DAY', label: __('Every day'), default: true }, + { key: 'EVERY_WEEK', label: __('Every week'), default: false }, + { key: 'EVERY_TWO_WEEKS', label: __('Every two weeks'), default: false }, + { key: 'EVERY_MONTH', label: __('Every month'), default: false }, + { key: 'EVERY_THREE_MONTHS', label: __('Every three months'), default: false }, +]; + +export const OLDER_THAN_OPTIONS = [ + { key: 'SEVEN_DAYS', variable: 7, default: false }, + { key: 'FOURTEEN_DAYS', variable: 14, default: false }, + { key: 'THIRTY_DAYS', variable: 30, default: false }, + { key: 'NINETY_DAYS', variable: 90, default: true }, +]; diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js index a7377773842..bdf1ab9507d 100644 --- a/app/assets/javascripts/registry/shared/utils.js +++ b/app/assets/javascripts/registry/shared/utils.js @@ -1,3 +1,6 @@ +import { n__ } from '~/locale'; +import { KEEP_N_OPTIONS, CADENCE_OPTIONS, OLDER_THAN_OPTIONS } from './constants'; + export const findDefaultOption = options => { const item = options.find(o => o.default); return item ? item.key : null; @@ -17,3 +20,27 @@ export const mapComputedToEvent = (list, root) => { }); return result; }; + +export const olderThanTranslationGenerator = variable => + n__( + '%d day until tags are automatically removed', + '%d days until tags are automatically removed', + variable, + ); + +export const keepNTranslationGenerator = variable => + n__('%d tag per image name', '%d tags per image name', variable); + +export const optionLabelGenerator = (collection, translationFn) => + collection.map(option => ({ + ...option, + label: translationFn(option.variable), + })); + +export const formOptionsGenerator = () => { + return { + olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator), + cadence: CADENCE_OPTIONS, + keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator), + }; +}; diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue index 63d61989cba..6fbae95094a 100644 --- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -195,7 +195,8 @@ export default { :disabled="isSubmitButtonDisabled" :loading="isSubmitting" type="submit" - class="js-add-issuable-form-add-button float-left qa-add-issue-button" + class="js-add-issuable-form-add-button float-left" + data-qa-selector="add_issue_button" > {{ __('Add') }} </gl-button> diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue index 31d0c7dbbb0..bbbdf2cdb49 100644 --- a/app/assets/javascripts/related_issues/components/issue_token.vue +++ b/app/assets/javascripts/related_issues/components/issue_token.vue @@ -90,6 +90,7 @@ export default { :size="12" :title="stateTitle" :aria-label="state" + data-testid="referenceIcon" /> {{ displayReference }} </component> @@ -105,6 +106,7 @@ export default { :title="removeButtonLabel" :aria-label="removeButtonLabel" :disabled="removeDisabled" + data-testid="removeBtn" type="button" class="js-issue-token-remove-button" @click="onRemoveRequest" diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue index 1931cfb2c00..9809b228308 100644 --- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue +++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue @@ -219,7 +219,8 @@ export default { :value="inputValue" :placeholder="inputPlaceholder" type="text" - class="js-add-issuable-form-input add-issuable-form-input qa-add-issue-input" + class="js-add-issuable-form-input add-issuable-form-input" + data-qa-selector="add_issue_field" @input="onInput" @focus="onFocus" @blur="onBlur" diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index e1edf3d689d..1a07e0ed762 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -1,13 +1,11 @@ <script> -/* eslint-disable vue/no-v-html */ import { mapState, mapActions, mapGetters } from 'vuex'; -import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { GlButton, GlFormInput, GlFormGroup, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; import { getParameterByName } from '~/lib/utils/common_utils'; import AssetLinksForm from './asset_links_form.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue'; import TagField from './tag_field.vue'; @@ -17,12 +15,12 @@ export default { GlFormInput, GlFormGroup, GlButton, + GlSprintf, MarkdownField, AssetLinksForm, MilestoneCombobox, TagField, }, - mixins: [glFeatureFlagsMixin()], computed: { ...mapState('detail', [ 'isFetchingRelease', @@ -41,18 +39,6 @@ export default { showForm() { return Boolean(!this.isFetchingRelease && !this.fetchError && this.release); }, - subtitleText() { - return sprintf( - __( - 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.', - ), - { - codeStart: '<code>', - codeEnd: '</code>', - }, - false, - ); - }, releaseTitle: { get() { return this.$store.state.detail.release.name; @@ -80,9 +66,6 @@ export default { cancelPath() { return getParameterByName(BACK_URL_PARAM) || this.releasesPagePath; }, - showAssetLinksForm() { - return this.glFeatures.releaseAssetLinkEditing; - }, saveButtonLabel() { return this.isExistingRelease ? __('Save changes') : __('Create release'); }, @@ -127,7 +110,19 @@ export default { </script> <template> <div class="d-flex flex-column"> - <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p> + <p class="pt-3 js-subtitle-text"> + <gl-sprintf + :message=" + __( + 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.', + ) + " + > + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> <form v-if="showForm" class="js-quick-submit" @submit.prevent="submitForm"> <tag-field /> <gl-form-group> @@ -150,7 +145,7 @@ export default { /> </div> </gl-form-group> - <gl-form-group> + <gl-form-group data-testid="release-notes"> <label for="release-notes">{{ __('Release notes') }}</label> <div class="bordered-box pr-3 pl-3"> <markdown-field @@ -158,6 +153,7 @@ export default { :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :add-spacing-classes="false" + :textarea-value="releaseNotes" class="gl-mt-3 gl-mb-3" > <template #textarea> @@ -175,7 +171,7 @@ export default { </div> </gl-form-group> - <asset-links-form v-if="showAssetLinksForm" /> + <asset-links-form /> <div class="d-flex pt-3"> <gl-button diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index b8cf6ce478f..422d8bf630d 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -1,29 +1,21 @@ <script> import { mapState, mapActions } from 'vuex'; -import { - GlDeprecatedSkeletonLoading as GlSkeletonLoading, - GlEmptyState, - GlLink, - GlButton, -} from '@gitlab/ui'; -import { - getParameterByName, - historyPushState, - buildUrlWithCurrentLocation, -} from '~/lib/utils/common_utils'; +import { GlEmptyState, GlLink, GlButton } from '@gitlab/ui'; +import { getParameterByName } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import ReleaseBlock from './release_block.vue'; +import ReleasesPagination from './releases_pagination.vue'; +import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; export default { name: 'ReleasesApp', components: { - GlSkeletonLoading, GlEmptyState, - ReleaseBlock, - TablePagination, GlLink, GlButton, + ReleaseBlock, + ReleasesPagination, + ReleaseSkeletonLoader, }, computed: { ...mapState('list', [ @@ -33,7 +25,6 @@ export default { 'isLoading', 'releases', 'hasError', - 'pageInfo', ]), shouldRenderEmptyState() { return !this.releases.length && !this.hasError && !this.isLoading; @@ -48,15 +39,23 @@ export default { }, }, created() { - this.fetchReleases({ - page: getParameterByName('page'), - }); + this.fetchReleases(); + + window.addEventListener('popstate', this.fetchReleases); }, methods: { - ...mapActions('list', ['fetchReleases']), - onChangePage(page) { - historyPushState(buildUrlWithCurrentLocation(`?page=${page}`)); - this.fetchReleases({ page }); + ...mapActions('list', { + fetchReleasesStoreAction: 'fetchReleases', + }), + fetchReleases() { + this.fetchReleasesStoreAction({ + // these two parameters are only used in "GraphQL mode" + before: getParameterByName('before'), + after: getParameterByName('after'), + + // this parameter is only used when in "REST mode" + page: getParameterByName('page'), + }); }, }, }; @@ -74,7 +73,7 @@ export default { {{ __('New release') }} </gl-button> - <gl-skeleton-loading v-if="isLoading" class="js-loading" /> + <release-skeleton-loader v-if="isLoading" class="js-loading" /> <gl-empty-state v-else-if="shouldRenderEmptyState" @@ -105,7 +104,7 @@ export default { /> </div> - <table-pagination v-if="!isLoading" :change="onChangePage" :page-info="pageInfo" /> + <releases-pagination v-if="!isLoading" /> </div> </template> <style> diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue index 8b89f0cf3fc..9ef38503c10 100644 --- a/app/assets/javascripts/releases/components/app_show.vue +++ b/app/assets/javascripts/releases/components/app_show.vue @@ -1,13 +1,13 @@ <script> import { mapState, mapActions } from 'vuex'; -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import ReleaseBlock from './release_block.vue'; +import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; export default { name: 'ReleaseShowApp', components: { - GlSkeletonLoading, ReleaseBlock, + ReleaseSkeletonLoader, }, computed: { ...mapState('detail', ['isFetchingRelease', 'fetchError', 'release']), @@ -22,7 +22,7 @@ export default { </script> <template> <div class="gl-mt-3"> - <gl-skeleton-loading v-if="isFetchingRelease" /> + <release-skeleton-loader v-if="isFetchingRelease" /> <release-block v-else-if="!fetchError" :release="release" /> </div> diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue index 07fab840067..331cc8ade6c 100644 --- a/app/assets/javascripts/releases/components/asset_links_form.vue +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -10,7 +10,6 @@ import { GlFormInput, GlFormSelect, } from '@gitlab/ui'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEFAULT_ASSET_LINK_TYPE, ASSET_LINK_TYPE } from '../constants'; import { s__ } from '~/locale'; @@ -26,7 +25,6 @@ export default { GlFormSelect, }, directives: { GlTooltip: GlTooltipDirective }, - mixins: [glFeatureFlagsMixin()], computed: { ...mapState('detail', ['release', 'releaseAssetsDocsPath']), ...mapGetters('detail', ['validationErrors']), @@ -195,7 +193,6 @@ export default { </gl-form-group> <gl-form-group - v-if="glFeatures.releaseAssetLinkType" class="link-type-field col-auto px-sm-2" :label="__('Type')" :label-for="`asset-type-${index}`" diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue index 3724162f6d5..6e6017637d4 100644 --- a/app/assets/javascripts/releases/components/evidence_block.vue +++ b/app/assets/javascripts/releases/components/evidence_block.vue @@ -83,11 +83,7 @@ export default { <span class="js-expanded monospace gl-pl-2">{{ sha(index) }}</span> </template> </expand-button> - <clipboard-button - :title="__('Copy evidence SHA')" - :text="sha(index)" - css-class="btn-default btn-transparent btn-clipboard" - /> + <clipboard-button :title="__('Copy evidence SHA')" :text="sha(index)" category="tertiary" /> </div> <div class="d-flex align-items-center text-muted"> diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index 2629df08be7..e9163a52792 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -11,7 +11,6 @@ import EvidenceBlock from './evidence_block.vue'; import ReleaseBlockAssets from './release_block_assets.vue'; import ReleaseBlockFooter from './release_block_footer.vue'; import ReleaseBlockHeader from './release_block_header.vue'; -import ReleaseBlockMetadata from './release_block_metadata.vue'; import ReleaseBlockMilestoneInfo from './release_block_milestone_info.vue'; export default { @@ -21,7 +20,6 @@ export default { ReleaseBlockAssets, ReleaseBlockFooter, ReleaseBlockHeader, - ReleaseBlockMetadata, ReleaseBlockMilestoneInfo, }, mixins: [glFeatureFlagsMixin()], @@ -54,22 +52,13 @@ export default { milestones() { return this.release.milestones || []; }, - shouldShowEvidence() { - return this.glFeatures.releaseEvidenceCollection; - }, - shouldShowFooter() { - return this.glFeatures.releaseIssueSummary; - }, shouldRenderAssets() { return Boolean( this.assets.links.length || (this.assets.sources && this.assets.sources.length), ); }, - shouldRenderReleaseMetaData() { - return !this.glFeatures.releaseIssueSummary; - }, shouldRenderMilestoneInfo() { - return Boolean(this.glFeatures.releaseIssueSummary && !isEmpty(this.release.milestones)); + return Boolean(!isEmpty(this.release.milestones)); }, }, @@ -105,9 +94,8 @@ export default { <hr class="mb-3 mt-0" /> </div> - <release-block-metadata v-if="shouldRenderReleaseMetaData" :release="release" /> <release-block-assets v-if="shouldRenderAssets" :assets="assets" /> - <evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" /> + <evidence-block v-if="hasEvidence" :release="release" /> <div ref="gfm-content" class="card-text gl-mt-3"> <div class="md" v-html="release.descriptionHtml"></div> @@ -115,7 +103,6 @@ export default { </div> <release-block-footer - v-if="shouldShowFooter" class="card-footer" :commit="release.commit" :commit-path="release.commitPath" diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue index 8824cbefd7e..eb83d8657c0 100644 --- a/app/assets/javascripts/releases/components/release_block_assets.vue +++ b/app/assets/javascripts/releases/components/release_block_assets.vue @@ -1,7 +1,6 @@ <script> import { GlTooltipDirective, GlLink, GlButton, GlCollapse, GlIcon, GlBadge } from '@gitlab/ui'; import { difference, get } from 'lodash'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { ASSET_LINK_TYPE } from '../constants'; import { __, s__, sprintf } from '~/locale'; @@ -17,7 +16,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagsMixin()], props: { assets: { type: Object, @@ -30,9 +28,6 @@ export default { }; }, computed: { - hasAssets() { - return Boolean(this.assets.count); - }, imageLinks() { return this.linksForType(ASSET_LINK_TYPE.IMAGE); }, @@ -95,94 +90,50 @@ export default { <template> <div class="card-text gl-mt-3"> - <template v-if="glFeatures.releaseAssetLinkType"> - <gl-button - data-testid="accordion-button" - variant="link" - class="gl-font-weight-bold" - @click="toggleAssetsExpansion" - > - <gl-icon - name="chevron-right" - class="gl-transition-medium" - :class="{ 'gl-rotate-90': isAssetsExpanded }" - /> - {{ __('Assets') }} - <gl-badge size="sm" variant="neutral" class="gl-display-inline-block">{{ - assets.count - }}</gl-badge> - </gl-button> - <gl-collapse v-model="isAssetsExpanded"> - <div class="gl-pl-6 gl-pt-3 js-assets-list"> - <template v-for="(section, index) in sections"> - <h5 v-if="section.title" :key="`section-header-${index}`" class="gl-mb-2"> - {{ section.title }} - </h5> - <ul :key="`section-body-${index}`" class="list-unstyled gl-m-0"> - <li v-for="link in section.links" :key="link.url"> - <gl-link - :href="link.directAssetUrl || link.url" - class="gl-display-flex gl-align-items-center gl-line-height-24" - > - <gl-icon - :name="section.iconName" - class="gl-mr-2 gl-flex-shrink-0 gl-flex-grow-0" - /> - {{ link.name }} - <gl-icon - v-if="link.external" - v-gl-tooltip - name="external-link" - :aria-label="$options.externalLinkTooltipText" - :title="$options.externalLinkTooltipText" - data-testid="external-link-indicator" - class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0 gl-text-gray-400" - /> - </gl-link> - </li> - </ul> - </template> - </div> - </gl-collapse> - </template> - - <template v-else> - <b> - {{ __('Assets') }} - <span class="js-assets-count badge badge-pill">{{ assets.count }}</span> - </b> - - <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"> - <gl-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)') - }}</span> - </gl-link> - </li> - </ul> - - <div v-if="hasAssets" class="dropdown"> - <button - type="button" - class="btn btn-link" - data-toggle="dropdown" - aria-haspopup="true" - aria-expanded="false" - > - <gl-icon name="doc-code" class="align-top gl-mr-2" /> - {{ __('Source code') }} - <gl-icon name="chevron-down" /> - </button> - - <div class="js-sources-dropdown dropdown-menu"> - <li v-for="asset in assets.sources" :key="asset.url"> - <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link> - </li> - </div> + <gl-button + data-testid="accordion-button" + variant="link" + class="gl-font-weight-bold" + @click="toggleAssetsExpansion" + > + <gl-icon + name="chevron-right" + class="gl-transition-medium" + :class="{ 'gl-rotate-90': isAssetsExpanded }" + /> + {{ __('Assets') }} + <gl-badge size="sm" variant="neutral" class="gl-display-inline-block">{{ + assets.count + }}</gl-badge> + </gl-button> + <gl-collapse v-model="isAssetsExpanded"> + <div class="gl-pl-6 gl-pt-3 js-assets-list"> + <template v-for="(section, index) in sections"> + <h5 v-if="section.title" :key="`section-header-${index}`" class="gl-mb-2"> + {{ section.title }} + </h5> + <ul :key="`section-body-${index}`" class="list-unstyled gl-m-0"> + <li v-for="link in section.links" :key="link.url" class="gl-display-flex"> + <gl-link + :href="link.directAssetUrl || link.url" + class="gl-display-flex gl-align-items-center gl-line-height-24" + > + <gl-icon :name="section.iconName" class="gl-mr-2 gl-flex-shrink-0 gl-flex-grow-0" /> + {{ link.name }} + <gl-icon + v-if="link.external" + v-gl-tooltip + name="external-link" + :aria-label="$options.externalLinkTooltipText" + :title="$options.externalLinkTooltipText" + data-testid="external-link-indicator" + class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0 gl-text-gray-400" + /> + </gl-link> + </li> + </ul> + </template> </div> - </template> + </gl-collapse> </div> </template> diff --git a/app/assets/javascripts/releases/components/release_block_author.vue b/app/assets/javascripts/releases/components/release_block_author.vue deleted file mode 100644 index 72c578068cd..00000000000 --- a/app/assets/javascripts/releases/components/release_block_author.vue +++ /dev/null @@ -1,42 +0,0 @@ -<script> -import { GlSprintf } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; - -export default { - name: 'ReleaseBlockAuthor', - components: { - GlSprintf, - UserAvatarLink, - }, - props: { - author: { - type: Object, - required: true, - }, - }, - computed: { - userImageAltDescription() { - return this.author && this.author.username - ? sprintf(__("%{username}'s avatar"), { username: this.author.username }) - : null; - }, - }, -}; -</script> - -<template> - <div class="d-flex"> - <gl-sprintf :message="__('by %{user}')"> - <template #user> - <user-avatar-link - class="gl-ml-2" - :link-href="author.webUrl" - :img-src="author.avatarUrl" - :img-alt="userImageAltDescription" - :tooltip-text="author.username" - /> - </template> - </gl-sprintf> - </div> -</template> diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue index 95292a26bce..87538244f1a 100644 --- a/app/assets/javascripts/releases/components/release_block_header.vue +++ b/app/assets/javascripts/releases/components/release_block_header.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective, GlLink, GlBadge, GlButton, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui'; import { BACK_URL_PARAM } from '~/releases/constants'; import { setUrlParams } from '~/lib/utils/url_utility'; @@ -8,7 +8,6 @@ export default { components: { GlLink, GlBadge, - GlIcon, GlButton, }, directives: { @@ -55,11 +54,10 @@ export default { v-gl-tooltip category="primary" variant="default" + icon="pencil" class="gl-mr-3 js-edit-button ml-2 pb-2" :title="__('Edit this release')" :href="editLink" - > - <gl-icon name="pencil" /> - </gl-button> + /> </div> </template> diff --git a/app/assets/javascripts/releases/components/release_block_metadata.vue b/app/assets/javascripts/releases/components/release_block_metadata.vue deleted file mode 100644 index 2247b4c0064..00000000000 --- a/app/assets/javascripts/releases/components/release_block_metadata.vue +++ /dev/null @@ -1,90 +0,0 @@ -<script> -import { GlTooltipDirective, GlLink, GlIcon } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; -import ReleaseBlockAuthor from './release_block_author.vue'; -import ReleaseBlockMilestones from './release_block_milestones.vue'; - -export default { - name: 'ReleaseBlockMetadata', - components: { - GlIcon, - GlLink, - ReleaseBlockAuthor, - ReleaseBlockMilestones, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [timeagoMixin], - props: { - release: { - type: Object, - required: true, - }, - }, - computed: { - author() { - return this.release.author; - }, - commit() { - return this.release.commit || {}; - }, - commitUrl() { - return this.release.commitPath; - }, - hasAuthor() { - return Boolean(this.author); - }, - releasedTimeAgo() { - const now = new Date(); - const isFuture = now < new Date(this.release.releasedAt); - const time = this.timeFormatted(this.release.releasedAt); - return isFuture - ? sprintf(__('will be released %{time}'), { time }) - : sprintf(__('released %{time}'), { time }); - }, - shouldRenderMilestones() { - return Boolean(this.release.milestones?.length); - }, - tagUrl() { - return this.release.tagPath; - }, - }, -}; -</script> - -<template> - <div class="card-subtitle d-flex flex-wrap text-secondary"> - <div class="gl-mr-3"> - <gl-icon name="commit" class="align-middle" /> - <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl"> - {{ commit.shortId }} - </gl-link> - <span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.shortId }}</span> - </div> - - <div class="gl-mr-3"> - <gl-icon name="tag" class="align-middle" /> - <gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl"> - {{ release.tagName }} - </gl-link> - <span v-else v-gl-tooltip.bottom :title="__('Tag')">{{ release.tagName }}</span> - </div> - - <release-block-milestones v-if="shouldRenderMilestones" :milestones="release.milestones" /> - - <div class="gl-mr-2"> - • - <span - v-gl-tooltip.bottom - class="js-release-date-info" - :title="tooltipTitle(release.releasedAt)" - > - {{ releasedTimeAgo }} - </span> - </div> - - <release-block-author v-if="hasAuthor" :author="author" /> - </div> -</template> diff --git a/app/assets/javascripts/releases/components/release_block_milestones.vue b/app/assets/javascripts/releases/components/release_block_milestones.vue deleted file mode 100644 index 1da683764b3..00000000000 --- a/app/assets/javascripts/releases/components/release_block_milestones.vue +++ /dev/null @@ -1,50 +0,0 @@ -<script> -import { GlTooltipDirective, GlLink, GlIcon } from '@gitlab/ui'; -import { n__ } from '~/locale'; - -export default { - name: 'ReleaseBlockMilestones', - components: { - GlLink, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - milestones: { - type: Array, - required: true, - }, - }, - computed: { - labelText() { - return n__('Milestone', 'Milestones', this.milestones.length); - }, - }, -}; -</script> - -<template> - <div> - <div class="js-milestone-list-label"> - <gl-icon name="flag" class="align-middle" /> - <span class="js-label-text">{{ labelText }}</span> - </div> - - <template v-for="(milestone, index) in milestones"> - <gl-link - :key="milestone.id" - v-gl-tooltip - :title="milestone.description" - :href="milestone.webUrl" - class="mx-1 js-milestone-link" - > - {{ milestone.title }} - </gl-link> - <template v-if="index !== milestones.length - 1"> - • - </template> - </template> - </div> -</template> diff --git a/app/assets/javascripts/releases/components/release_skeleton_loader.vue b/app/assets/javascripts/releases/components/release_skeleton_loader.vue new file mode 100644 index 00000000000..054620af636 --- /dev/null +++ b/app/assets/javascripts/releases/components/release_skeleton_loader.vue @@ -0,0 +1,51 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + name: 'ReleaseSkeletonLoader', + components: { GlSkeletonLoader }, +}; +</script> +<template> + <gl-skeleton-loader :width="1248" :height="420"> + <!-- Outside border --> + <path + d="M 4.5 0 C 2.0156486 0 0 2.0156486 0 4.5 L 0 415.5 C 0 417.98435 2.0156486 420 4.5 420 L 1243.5 420 C 1245.9844 420 1248 417.98435 1248 415.5 L 1248 4.5 C 1248 2.0156486 1245.9844 0 1243.5 0 L 4.5 0 z M 4.5 1 L 1243.5 1 C 1245.4476 1 1247 2.5523514 1247 4.5 L 1247 415.5 C 1247 417.44765 1245.4476 419 1243.5 419 L 4.5 419 C 2.5523514 419 1 417.44765 1 415.5 L 1 4.5 C 1 2.5523514 2.5523514 1 4.5 1 z " + /> + + <!-- Header bottom border --> + <rect x="0" y="63.5" width="1248" height="1" /> + + <!-- Release title --> + <rect x="16" y="20" width="293" height="24" /> + + <!-- Edit (pencil) button --> + <rect x="1207" y="16" rx="4" width="32" height="32" /> + + <!-- Asset link 1 --> + <rect x="40" y="121" rx="4" width="16" height="16" /> + <rect x="60" y="125" width="116" height="8" /> + + <!-- Asset link 2 --> + <rect x="40" y="145" rx="4" width="16" height="16" /> + <rect x="60" y="149" width="132" height="8" /> + + <!-- Asset link 3 --> + <rect x="40" y="169" rx="4" width="16" height="16" /> + <rect x="60" y="173" width="140" height="8" /> + + <!-- Asset link 4 --> + <rect x="40" y="193" rx="4" width="16" height="16" /> + <rect x="60" y="197" width="112" height="8" /> + + <!-- Release notes --> + <rect x="16" y="228" width="480" height="8" /> + <rect x="16" y="252" width="560" height="8" /> + <rect x="16" y="276" width="480" height="8" /> + <rect x="16" y="300" width="560" height="8" /> + <rect x="16" y="324" width="320" height="8" /> + + <!-- Footer top border --> + <rect x="0" y="373" width="1248" height="1" /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue index a4fe407a5bd..cb6f1fa18a1 100644 --- a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue +++ b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue @@ -13,14 +13,14 @@ export default { }, }, methods: { - ...mapActions('list', ['fetchReleasesGraphQl']), + ...mapActions('list', ['fetchReleases']), onPrev(before) { historyPushState(buildUrlWithCurrentLocation(`?before=${before}`)); - this.fetchReleasesGraphQl({ before }); + this.fetchReleases({ before }); }, onNext(after) { historyPushState(buildUrlWithCurrentLocation(`?after=${after}`)); - this.fetchReleasesGraphQl({ after }); + this.fetchReleases({ after }); }, }, }; diff --git a/app/assets/javascripts/releases/components/releases_pagination_rest.vue b/app/assets/javascripts/releases/components/releases_pagination_rest.vue index 992cc4cd469..334458a2302 100644 --- a/app/assets/javascripts/releases/components/releases_pagination_rest.vue +++ b/app/assets/javascripts/releases/components/releases_pagination_rest.vue @@ -7,18 +7,18 @@ export default { name: 'ReleasesPaginationRest', components: { TablePagination }, computed: { - ...mapState('list', ['pageInfo']), + ...mapState('list', ['restPageInfo']), }, methods: { - ...mapActions('list', ['fetchReleasesRest']), + ...mapActions('list', ['fetchReleases']), onChangePage(page) { historyPushState(buildUrlWithCurrentLocation(`?page=${page}`)); - this.fetchReleasesRest({ page }); + this.fetchReleases({ page }); }, }, }; </script> <template> - <table-pagination :change="onChangePage" :page-info="pageInfo" /> + <table-pagination :change="onChangePage" :page-info="restPageInfo" /> </template> diff --git a/app/assets/javascripts/releases/constants.js b/app/assets/javascripts/releases/constants.js index 361cee70747..953e7b4189c 100644 --- a/app/assets/javascripts/releases/constants.js +++ b/app/assets/javascripts/releases/constants.js @@ -10,3 +10,5 @@ export const ASSET_LINK_TYPE = Object.freeze({ }); export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER; + +export const PAGE_SIZE = 20; diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js index 623b18591a0..2f4b0e64e36 100644 --- a/app/assets/javascripts/releases/mount_edit.js +++ b/app/assets/javascripts/releases/mount_edit.js @@ -13,9 +13,6 @@ export default () => { modules: { detail: createDetailModule(el.dataset), }, - featureFlags: { - releaseShowPage: Boolean(gon.features?.releaseShowPage), - }, }); return new Vue({ diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js index 10725e47740..5c481498ffb 100644 --- a/app/assets/javascripts/releases/mount_new.js +++ b/app/assets/javascripts/releases/mount_new.js @@ -13,9 +13,6 @@ export default () => { modules: { detail: createDetailModule(el.dataset), }, - featureFlags: { - releaseShowPage: Boolean(gon.features?.releaseShowPage), - }, }); return new Vue({ diff --git a/app/assets/javascripts/releases/mount_show.js b/app/assets/javascripts/releases/mount_show.js index eef015ee0a6..b09ecc9fb55 100644 --- a/app/assets/javascripts/releases/mount_show.js +++ b/app/assets/javascripts/releases/mount_show.js @@ -13,6 +13,9 @@ export default () => { modules: { detail: createDetailModule(el.dataset), }, + featureFlags: { + graphqlIndividualReleasePage: Boolean(gon.features?.graphqlIndividualReleasePage), + }, }); return new Vue({ diff --git a/app/assets/javascripts/releases/queries/all_releases.query.graphql b/app/assets/javascripts/releases/queries/all_releases.query.graphql index 7a99f32fdfa..c35306f163d 100644 --- a/app/assets/javascripts/releases/queries/all_releases.query.graphql +++ b/app/assets/javascripts/releases/queries/all_releases.query.graphql @@ -1,68 +1,16 @@ -query allReleases($fullPath: ID!) { +#import "./release.fragment.graphql" + +query allReleases($fullPath: ID!, $first: Int, $last: Int, $before: String, $after: String) { project(fullPath: $fullPath) { - releases(first: 20) { - count + releases(first: $first, last: $last, before: $before, after: $after) { nodes { - name - tagName - tagPath - descriptionHtml - releasedAt - upcomingRelease - assets { - count - sources { - nodes { - format - url - } - } - links { - nodes { - id - name - url - directAssetUrl - linkType - external - } - } - } - evidences { - nodes { - filepath - collectedAt - sha - } - } - links { - editUrl - issuesUrl - mergeRequestsUrl - selfUrl - } - commit { - sha - webUrl - title - } - author { - webUrl - avatarUrl - username - } - milestones { - nodes { - id - title - description - webPath - stats { - totalIssuesCount - closedIssuesCount - } - } - } + ...Release + } + pageInfo { + startCursor + hasPreviousPage + hasNextPage + endCursor } } } diff --git a/app/assets/javascripts/releases/queries/one_release.query.graphql b/app/assets/javascripts/releases/queries/one_release.query.graphql new file mode 100644 index 00000000000..b893aea94b0 --- /dev/null +++ b/app/assets/javascripts/releases/queries/one_release.query.graphql @@ -0,0 +1,9 @@ +#import "./release.fragment.graphql" + +query oneRelease($fullPath: ID!, $tagName: String!) { + project(fullPath: $fullPath) { + release(tagName: $tagName) { + ...Release + } + } +} diff --git a/app/assets/javascripts/releases/queries/release.fragment.graphql b/app/assets/javascripts/releases/queries/release.fragment.graphql new file mode 100644 index 00000000000..445ed616348 --- /dev/null +++ b/app/assets/javascripts/releases/queries/release.fragment.graphql @@ -0,0 +1,62 @@ +fragment Release on Release { + name + tagName + tagPath + descriptionHtml + releasedAt + upcomingRelease + assets { + count + sources { + nodes { + format + url + } + } + links { + nodes { + id + name + url + directAssetUrl + linkType + external + } + } + } + evidences { + nodes { + filepath + collectedAt + sha + } + } + links { + editUrl + issuesUrl + mergeRequestsUrl + selfUrl + } + commit { + sha + webUrl + title + } + author { + webUrl + avatarUrl + username + } + milestones { + nodes { + id + title + description + webPath + stats { + totalIssuesCount + closedIssuesCount + } + } + } +} diff --git a/app/assets/javascripts/releases/stores/getters.js b/app/assets/javascripts/releases/stores/getters.js new file mode 100644 index 00000000000..6a1da63289c --- /dev/null +++ b/app/assets/javascripts/releases/stores/getters.js @@ -0,0 +1,11 @@ +/** + * @returns {Boolean} `true` if all the feature flags + * required to enable the GraphQL endpoint are enabled + */ +export const useGraphQLEndpoint = rootState => { + return Boolean( + rootState.featureFlags.graphqlReleaseData && + rootState.featureFlags.graphqlReleasesPage && + rootState.featureFlags.graphqlMilestoneStats, + ); +}; diff --git a/app/assets/javascripts/releases/stores/index.js b/app/assets/javascripts/releases/stores/index.js index b2e93d789d7..cc8b586964f 100644 --- a/app/assets/javascripts/releases/stores/index.js +++ b/app/assets/javascripts/releases/stores/index.js @@ -1,7 +1,9 @@ import Vuex from 'vuex'; +import * as getters from './getters'; export default ({ modules, featureFlags }) => new Vuex.Store({ modules, state: { featureFlags }, + getters, }); diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js index 5b682a0ab0f..e8a46f40d20 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/actions.js +++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js @@ -3,7 +3,13 @@ import api from '~/api'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; import { redirectTo } from '~/lib/utils/url_utility'; -import { releaseToApiJson, apiJsonToRelease } from '~/releases/util'; +import { + releaseToApiJson, + apiJsonToRelease, + gqClient, + convertOneReleaseGraphQLResponse, +} from '~/releases/util'; +import oneReleaseQuery from '~/releases/queries/one_release.query.graphql'; export const initializeRelease = ({ commit, dispatch, getters }) => { if (getters.isExistingRelease) { @@ -18,9 +24,29 @@ export const initializeRelease = ({ commit, dispatch, getters }) => { return Promise.resolve(); }; -export const fetchRelease = ({ commit, state }) => { +export const fetchRelease = ({ commit, state, rootState }) => { commit(types.REQUEST_RELEASE); + if (rootState.featureFlags?.graphqlIndividualReleasePage) { + return gqClient + .query({ + query: oneReleaseQuery, + variables: { + fullPath: state.projectPath, + tagName: state.tagName, + }, + }) + .then(response => { + const { data: release } = convertOneReleaseGraphQLResponse(response); + + commit(types.RECEIVE_RELEASE_SUCCESS, release); + }) + .catch(error => { + commit(types.RECEIVE_RELEASE_ERROR, error); + createFlash(s__('Release|Something went wrong while getting the release details')); + }); + } + return api .release(state.projectId, state.tagName) .then(({ data }) => { @@ -45,6 +71,9 @@ export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_REL export const updateReleaseMilestones = ({ commit }, milestones) => commit(types.UPDATE_RELEASE_MILESTONES, milestones); +export const updateReleaseGroupMilestones = ({ commit }, groupMilestones) => + commit(types.UPDATE_RELEASE_GROUP_MILESTONES, groupMilestones); + export const addEmptyAssetLink = ({ commit }) => { commit(types.ADD_EMPTY_ASSET_LINK); }; @@ -65,9 +94,9 @@ export const removeAssetLink = ({ commit }, linkIdToRemove) => { commit(types.REMOVE_ASSET_LINK, linkIdToRemove); }; -export const receiveSaveReleaseSuccess = ({ commit, state, rootState }, release) => { +export const receiveSaveReleaseSuccess = ({ commit }, release) => { commit(types.RECEIVE_SAVE_RELEASE_SUCCESS); - redirectTo(rootState.featureFlags.releaseShowPage ? release._links.self : state.releasesPagePath); + redirectTo(release._links.self); }; export const saveRelease = ({ commit, dispatch, getters }) => { diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js index 7784e0cc741..1b2f5f33f02 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js @@ -9,6 +9,7 @@ export const UPDATE_CREATE_FROM = 'UPDATE_CREATE_FROM'; export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE'; export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES'; export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES'; +export const UPDATE_RELEASE_GROUP_MILESTONES = 'UPDATE_RELEASE_GROUP_MILESTONES'; export const REQUEST_SAVE_RELEASE = 'REQUEST_SAVE_RELEASE'; export const RECEIVE_SAVE_RELEASE_SUCCESS = 'RECEIVE_SAVE_RELEASE_SUCCESS'; diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js index 750f496665d..58a1958c5e2 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js @@ -13,6 +13,7 @@ export default { name: '', description: '', milestones: [], + groupMilestones: [], assets: { links: [], }, @@ -51,6 +52,10 @@ export default { state.release.milestones = milestones; }, + [types.UPDATE_RELEASE_GROUP_MILESTONES](state, groupMilestones) { + state.release.groupMilestones = groupMilestones; + }, + [types.REQUEST_SAVE_RELEASE](state) { state.isUpdatingRelease = true; }, diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js index a46e750df53..782a5c46d6c 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/state.js +++ b/app/assets/javascripts/releases/stores/modules/detail/state.js @@ -1,5 +1,6 @@ export default ({ projectId, + projectPath, markdownDocsPath, markdownPreviewPath, updateReleaseApiDocsPath, @@ -12,6 +13,7 @@ export default ({ defaultBranch = null, }) => ({ projectId, + projectPath, markdownDocsPath, markdownPreviewPath, updateReleaseApiDocsPath, diff --git a/app/assets/javascripts/releases/stores/modules/list/actions.js b/app/assets/javascripts/releases/stores/modules/list/actions.js index 945b093b983..02e67415e63 100644 --- a/app/assets/javascripts/releases/stores/modules/list/actions.js +++ b/app/assets/javascripts/releases/stores/modules/list/actions.js @@ -8,55 +8,90 @@ import { convertObjectPropsToCamelCase, } from '~/lib/utils/common_utils'; import allReleasesQuery from '~/releases/queries/all_releases.query.graphql'; -import { gqClient, convertGraphQLResponse } from '../../../util'; +import { gqClient, convertAllReleasesGraphQLResponse } from '../../../util'; +import { PAGE_SIZE } from '../../../constants'; /** - * Commits a mutation to update the state while the main endpoint is being requested. + * Gets a paginated list of releases from the server + * + * @param {Object} vuexParams + * @param {Object} actionParams + * @param {Number} [actionParams.page] The page number of results to fetch + * (this parameter is only used when fetching results from the REST API) + * @param {String} [actionParams.before] A GraphQL cursor. If provided, + * the items returned will proceed the provided cursor (this parameter is only + * used when fetching results from the GraphQL API). + * @param {String} [actionParams.after] A GraphQL cursor. If provided, + * the items returned will follow the provided cursor (this parameter is only + * used when fetching results from the GraphQL API). */ -export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES); +export const fetchReleases = ({ dispatch, rootGetters }, { page = 1, before, after }) => { + if (rootGetters.useGraphQLEndpoint) { + dispatch('fetchReleasesGraphQl', { before, after }); + } else { + dispatch('fetchReleasesRest', { page }); + } +}; /** - * Fetches the main endpoint. - * Will dispatch requestNamespace action before starting the request. - * Will dispatch receiveNamespaceSuccess if the request is successful - * Will dispatch receiveNamesapceError if the request returns an error - * - * @param {String} projectId + * Gets a paginated list of releases from the GraphQL endpoint */ -export const fetchReleases = ({ dispatch, rootState, state }, { page = '1' }) => { - dispatch('requestReleases'); +export const fetchReleasesGraphQl = ( + { dispatch, commit, state }, + { before = null, after = null }, +) => { + commit(types.REQUEST_RELEASES); - if ( - rootState.featureFlags.graphqlReleaseData && - rootState.featureFlags.graphqlReleasesPage && - rootState.featureFlags.graphqlMilestoneStats - ) { - gqClient - .query({ - query: allReleasesQuery, - variables: { - fullPath: state.projectPath, - }, - }) - .then(response => { - dispatch('receiveReleasesSuccess', convertGraphQLResponse(response)); - }) - .catch(() => dispatch('receiveReleasesError')); + let paginationParams; + if (!before && !after) { + paginationParams = { first: PAGE_SIZE }; + } else if (before && !after) { + paginationParams = { last: PAGE_SIZE, before }; + } else if (!before && after) { + paginationParams = { first: PAGE_SIZE, after }; } else { - api - .releases(state.projectId, { page }) - .then(response => dispatch('receiveReleasesSuccess', response)) - .catch(() => dispatch('receiveReleasesError')); + throw new Error( + 'Both a `before` and an `after` parameter were provided to fetchReleasesGraphQl. These parameters cannot be used together.', + ); } + + gqClient + .query({ + query: allReleasesQuery, + variables: { + fullPath: state.projectPath, + ...paginationParams, + }, + }) + .then(response => { + const { data, paginationInfo: graphQlPageInfo } = convertAllReleasesGraphQLResponse(response); + + commit(types.RECEIVE_RELEASES_SUCCESS, { + data, + graphQlPageInfo, + }); + }) + .catch(() => dispatch('receiveReleasesError')); }; -export const receiveReleasesSuccess = ({ commit }, { data, headers }) => { - const pageInfo = parseIntPagination(normalizeHeaders(headers)); - const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true }); - commit(types.RECEIVE_RELEASES_SUCCESS, { - data: camelCasedReleases, - pageInfo, - }); +/** + * Gets a paginated list of releases from the REST endpoint + */ +export const fetchReleasesRest = ({ dispatch, commit, state }, { page }) => { + commit(types.REQUEST_RELEASES); + + api + .releases(state.projectId, { page }) + .then(({ data, headers }) => { + const restPageInfo = parseIntPagination(normalizeHeaders(headers)); + const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true }); + + commit(types.RECEIVE_RELEASES_SUCCESS, { + data: camelCasedReleases, + restPageInfo, + }); + }) + .catch(() => dispatch('receiveReleasesError')); }; export const receiveReleasesError = ({ commit }) => { diff --git a/app/assets/javascripts/releases/stores/modules/list/mutations.js b/app/assets/javascripts/releases/stores/modules/list/mutations.js index 99fc096264a..296487cfee2 100644 --- a/app/assets/javascripts/releases/stores/modules/list/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/list/mutations.js @@ -17,11 +17,12 @@ export default { * @param {Object} state * @param {Object} resp */ - [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) { + [types.RECEIVE_RELEASES_SUCCESS](state, { data, restPageInfo, graphQlPageInfo }) { state.hasError = false; state.isLoading = false; state.releases = data; - state.pageInfo = pageInfo; + state.restPageInfo = restPageInfo; + state.graphQlPageInfo = graphQlPageInfo; }, /** @@ -35,5 +36,7 @@ export default { state.isLoading = false; state.releases = []; state.hasError = true; + state.restPageInfo = {}; + state.graphQlPageInfo = {}; }, }; diff --git a/app/assets/javascripts/releases/stores/modules/list/state.js b/app/assets/javascripts/releases/stores/modules/list/state.js index 9fe313745fc..0bffaa0f9db 100644 --- a/app/assets/javascripts/releases/stores/modules/list/state.js +++ b/app/assets/javascripts/releases/stores/modules/list/state.js @@ -14,5 +14,6 @@ export default ({ isLoading: false, hasError: false, releases: [], - pageInfo: {}, + restPageInfo: {}, + graphQlPageInfo: {}, }); diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js index d7fac7a9b65..445c429fd96 100644 --- a/app/assets/javascripts/releases/util.js +++ b/app/assets/javascripts/releases/util.js @@ -107,7 +107,24 @@ const convertMilestones = graphQLRelease => ({ }); /** - * Converts the response from the GraphQL endpoint into the + * Converts a single release object fetched from GraphQL + * into a release object that matches the shape of the REST API + * (the same shape that is returned by `apiJsonToRelease` above.) + * + * @param graphQLRelease The release object returned from a GraphQL query + */ +export const convertGraphQLRelease = graphQLRelease => ({ + ...convertScalarProperties(graphQLRelease), + ...convertAssets(graphQLRelease), + ...convertEvidences(graphQLRelease), + ...convertLinks(graphQLRelease), + ...convertCommit(graphQLRelease), + ...convertAuthor(graphQLRelease), + ...convertMilestones(graphQLRelease), +}); + +/** + * Converts the response from all_releases.query.graphql into the * same shape as is returned from the Releases REST API. * * This allows the release components to use the response @@ -115,16 +132,27 @@ const convertMilestones = graphQLRelease => ({ * * @param response The response received from the GraphQL endpoint */ -export const convertGraphQLResponse = response => { - const releases = response.data.project.releases.nodes.map(r => ({ - ...convertScalarProperties(r), - ...convertAssets(r), - ...convertEvidences(r), - ...convertLinks(r), - ...convertCommit(r), - ...convertAuthor(r), - ...convertMilestones(r), - })); - - return { data: releases }; +export const convertAllReleasesGraphQLResponse = response => { + const releases = response.data.project.releases.nodes.map(convertGraphQLRelease); + + const paginationInfo = { + ...response.data.project.releases.pageInfo, + }; + + return { data: releases, paginationInfo }; +}; + +/** + * Converts the response from one_release.query.graphql into the + * same shape as is returned from the Releases REST API. + * + * This allows the release components to use the response + * from either endpoint interchangeably. + * + * @param response The response received from the GraphQL endpoint + */ +export const convertOneReleaseGraphQLResponse = response => { + const release = convertGraphQLRelease(response.data.project.release); + + return { data: release }; }; diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 74437f286b4..677cb265942 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -1,9 +1,9 @@ <script> import { - GlDeprecatedDropdown, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownHeader, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownDivider, + GlDropdownSectionHeader, + GlDropdownItem, GlIcon, } from '@gitlab/ui'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; @@ -20,10 +20,10 @@ const ROW_TYPES = { export default { components: { - GlDeprecatedDropdown, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownHeader, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownDivider, + GlDropdownSectionHeader, + GlDropdownItem, GlIcon, }, apollo: { @@ -226,11 +226,11 @@ export default { getComponent(type) { switch (type) { case ROW_TYPES.divider: - return 'gl-deprecated-dropdown-divider'; + return 'gl-dropdown-divider'; case ROW_TYPES.header: - return 'gl-deprecated-dropdown-header'; + return 'gl-dropdown-section-header'; default: - return 'gl-deprecated-dropdown-item'; + return 'gl-dropdown-item'; } }, }, @@ -246,7 +246,7 @@ export default { </router-link> </li> <li v-if="renderAddToTreeDropdown" class="breadcrumb-item"> - <gl-deprecated-dropdown toggle-class="add-to-tree qa-add-to-tree ml-1"> + <gl-dropdown toggle-class="add-to-tree qa-add-to-tree gl-ml-2"> <template #button-content> <span class="sr-only">{{ __('Add to tree') }}</span> <gl-icon name="plus" :size="16" class="float-left" /> @@ -257,7 +257,7 @@ export default { {{ item.text }} </component> </template> - </gl-deprecated-dropdown> + </gl-dropdown> </li> </ol> </nav> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 59831890a4e..0e2bccfabdd 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -1,7 +1,8 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlTooltipDirective, GlLink, GlDeprecatedButton, GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; import defaultAvatarUrl from 'images/no_avatar.png'; +import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; import { sprintf, s__ } from '~/locale'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; @@ -9,17 +10,16 @@ import CiIcon from '../../vue_shared/components/ci_icon.vue'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; -import pathLastCommitQuery from '../queries/path_last_commit.query.graphql'; export default { components: { - GlIcon, UserAvatarLink, TimeagoTooltip, ClipboardButton, CiIcon, + GlButton, + GlButtonGroup, GlLink, - GlDeprecatedButton, GlLoadingIcon, }, directives: { @@ -123,15 +123,14 @@ export default { class="commit-row-message item-title" v-html="commit.titleHtml" /> - <gl-deprecated-button + <gl-button v-if="commit.descriptionHtml" :class="{ open: showDescription }" :aria-label="__('Show commit description')" - class="text-expander" + class="text-expander gl-vertical-align-bottom!" + icon="ellipsis_h" @click="toggleShowDescription" - > - <gl-icon name="ellipsis_h" :size="10" /> - </gl-deprecated-button> + /> <div class="committer"> <gl-link v-if="commit.author" @@ -169,16 +168,19 @@ export default { /> </gl-link> </div> - <div class="commit-sha-group d-flex"> - <div class="label label-monospace monospace"> - {{ showCommitId }} - </div> + <gl-button-group class="gl-ml-4 js-commit-sha-group"> + <gl-button + label + class="gl-font-monospace" + data-testid="last-commit-id-label" + v-text="showCommitId" + /> <clipboard-button :text="commit.sha" :title="__('Copy commit SHA')" - tooltip-placement="bottom" + class="input-group-text" /> - </div> + </gl-button-group> </div> </div> </template> diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index eca53f73a7f..4e2c8332f37 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -2,7 +2,7 @@ /* eslint-disable vue/no-v-html */ import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; -import { GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { handleLocationHash } from '~/lib/utils/common_utils'; import readmeQuery from '../../queries/readme.query.graphql'; @@ -19,6 +19,7 @@ export default { }, }, components: { + GlIcon, GlLink, GlLoadingIcon, }, @@ -51,7 +52,7 @@ export default { <article class="file-holder limited-width-container readme-holder"> <div class="js-file-title file-title-flex-parent"> <div class="file-header-content"> - <i aria-hidden="true" class="fa fa-file-text-o fa-fw"></i> + <gl-icon name="doc-text" aria-hidden="true" /> <gl-link :href="blob.webPath"> <strong>{{ blob.name }}</strong> </gl-link> diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 365b6cbb550..78b8baaa75e 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -75,6 +75,7 @@ export default { }, methods: { fetchFiles() { + const originalPath = this.path || '/'; this.isLoadingFiles = true; return this.$apollo @@ -83,14 +84,14 @@ export default { variables: { projectPath: this.projectPath, ref: this.ref, - path: this.path || '/', + path: originalPath, nextPageCursor: this.nextPageCursor, pageSize: this.pageSize, }, }) .then(({ data }) => { if (data.errors) throw data.errors; - if (!data?.project?.repository) return; + if (!data?.project?.repository || originalPath !== (this.path || '/')) return; const pageInfo = this.hasNextPage(data.project.repository.tree); diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 7f72524b6fe..a62b2d96c54 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -1,16 +1,17 @@ import Vue from 'vue'; -import { escapeFileUrl, joinPaths, webIDEUrl } from '../lib/utils/url_utility'; +import PathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; +import { escapeFileUrl } from '../lib/utils/url_utility'; import createRouter from './router'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import LastCommit from './components/last_commit.vue'; import TreeActionLink from './components/tree_action_link.vue'; -import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; +import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; import apolloProvider from './graphql'; import { setTitle } from './utils/title'; import { updateFormAction } from './utils/dom'; -import { convertObjectPropsToCamelCase, parseBoolean } from '../lib/utils/common_utils'; +import { parseBoolean } from '../lib/utils/common_utils'; import { __ } from '../locale'; export default function setupVueRepositoryList() { @@ -18,6 +19,10 @@ export default function setupVueRepositoryList() { const { dataset } = el; const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset; const router = createRouter(projectPath, escapedRef); + const pathRegex = /-\/tree\/[^/]+\/(.+$)/; + const matches = window.location.href.match(pathRegex); + + const currentRoutePath = matches ? matches[1] : ''; apolloProvider.clients.defaultClient.cache.writeData({ data: { @@ -29,6 +34,43 @@ export default function setupVueRepositoryList() { }, }); + const initLastCommitApp = () => + new Vue({ + el: document.getElementById('js-last-commit'), + router, + apolloProvider, + render(h) { + return h(LastCommit, { + props: { + currentPath: this.$route.params.path, + }, + }); + }, + }); + + if (window.gl.startup_graphql_calls) { + const query = window.gl.startup_graphql_calls.find( + call => call.operationName === 'pathLastCommit', + ); + query.fetchCall + .then(res => res.json()) + .then(res => { + apolloProvider.clients.defaultClient.writeQuery({ + query: PathLastCommitQuery, + data: res.data, + variables: { + projectPath, + ref, + path: currentRoutePath, + }, + }); + }) + .catch(() => {}) + .finally(() => initLastCommitApp()); + } else { + initLastCommitApp(); + } + router.afterEach(({ params: { path } }) => { setTitle(path, ref, fullName); }); @@ -77,20 +119,6 @@ export default function setupVueRepositoryList() { }); } - // eslint-disable-next-line no-new - new Vue({ - el: document.getElementById('js-last-commit'), - router, - apolloProvider, - render(h) { - return h(LastCommit, { - props: { - currentPath: this.$route.params.path, - }, - }); - }, - }); - const treeHistoryLinkEl = document.getElementById('js-tree-history-link'); const { historyLink } = treeHistoryLinkEl.dataset; @@ -110,29 +138,7 @@ export default function setupVueRepositoryList() { }, }); - const webIdeLinkEl = document.getElementById('js-tree-web-ide-link'); - - if (webIdeLinkEl) { - const { ideBasePath, ...options } = convertObjectPropsToCamelCase( - JSON.parse(webIdeLinkEl.dataset.options), - ); - - // eslint-disable-next-line no-new - new Vue({ - el: webIdeLinkEl, - router, - render(h) { - return h(WebIdeLink, { - props: { - webIdeUrl: webIDEUrl( - joinPaths('/', ideBasePath, 'edit', ref, '-', this.$route.params.path || '', '/'), - ), - ...options, - }, - }); - }, - }); - } + initWebIdeLink({ el: document.getElementById('js-tree-web-ide-link'), router }); const directoryDownloadLinks = document.getElementById('js-directory-downloads'); diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js index 361e0b62bb7..fc8fa40a855 100644 --- a/app/assets/javascripts/repository/log_tree.js +++ b/app/assets/javascripts/repository/log_tree.js @@ -5,8 +5,8 @@ import commitsQuery from './queries/commits.query.graphql'; import projectPathQuery from './queries/project_path.query.graphql'; import refQuery from './queries/ref.query.graphql'; -let fetchpromise; -let resolvers = []; +const fetchpromises = {}; +const resolvers = {}; export function resolveCommit(commits, path, { resolve, entry }) { const commit = commits.find(c => c.filePath === `${path}/${entry.name}` && c.type === entry.type); @@ -18,15 +18,19 @@ export function resolveCommit(commits, path, { resolve, entry }) { export function fetchLogsTree(client, path, offset, resolver = null) { if (resolver) { - resolvers.push(resolver); + if (!resolvers[path]) { + resolvers[path] = [resolver]; + } else { + resolvers[path].push(resolver); + } } - if (fetchpromise) return fetchpromise; + if (fetchpromises[path]) return fetchpromises[path]; const { projectPath } = client.readQuery({ query: projectPathQuery }); const { escapedRef } = client.readQuery({ query: refQuery }); - fetchpromise = axios + fetchpromises[path] = axios .get( `${gon.relative_url_root}/${projectPath}/-/refs/${escapedRef}/logs_tree/${encodeURIComponent( path.replace(/^\//, ''), @@ -46,16 +50,16 @@ export function fetchLogsTree(client, path, offset, resolver = null) { data, }); - resolvers.forEach(r => resolveCommit(data.commits, path, r)); + resolvers[path].forEach(r => resolveCommit(data.commits, path, r)); - fetchpromise = null; + delete fetchpromises[path]; if (headerLogsOffset) { fetchLogsTree(client, path, headerLogsOffset); } else { - resolvers = []; + delete resolvers[path]; } }); - return fetchpromise; + return fetchpromises[path]; } diff --git a/app/assets/javascripts/repository/queries/path_last_commit.query.graphql b/app/assets/javascripts/repository/queries/path_last_commit.query.graphql deleted file mode 100644 index 51f3f790a5d..00000000000 --- a/app/assets/javascripts/repository/queries/path_last_commit.query.graphql +++ /dev/null @@ -1,38 +0,0 @@ -query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { - project(fullPath: $projectPath) { - repository { - tree(path: $path, ref: $ref) { - lastCommit { - sha - title - titleHtml - descriptionHtml - message - webPath - authoredDate - authorName - authorGravatar - author { - name - avatarUrl - webPath - } - signatureHtml - pipelines(ref: $ref, first: 1) { - edges { - node { - detailedStatus { - detailsPath - icon - tooltip - text - group - } - } - } - } - } - } - } - } -} diff --git a/app/assets/javascripts/repository/utils/icon.js b/app/assets/javascripts/repository/utils/icon.js deleted file mode 100644 index 47b045c7eaf..00000000000 --- a/app/assets/javascripts/repository/utils/icon.js +++ /dev/null @@ -1,98 +0,0 @@ -const entryTypeIcons = { - tree: 'folder', - commit: 'archive', -}; - -const fileTypeIcons = [ - { extensions: ['pdf'], name: 'file-pdf-o' }, - { - extensions: [ - 'jpg', - 'jpeg', - 'jif', - 'jfif', - 'jp2', - 'jpx', - 'j2k', - 'j2c', - 'png', - 'gif', - 'tif', - 'tiff', - 'svg', - 'ico', - 'bmp', - ], - name: 'file-image-o', - }, - { - extensions: ['zip', 'zipx', 'tar', 'gz', 'bz', 'bzip', 'xz', 'rar', '7z'], - name: 'file-archive-o', - }, - { extensions: ['mp3', 'wma', 'ogg', 'oga', 'wav', 'flac', 'aac'], name: 'file-audio-o' }, - { - extensions: [ - 'mp4', - 'm4p', - 'm4v', - 'mpg', - 'mp2', - 'mpeg', - 'mpe', - 'mpv', - 'm2v', - 'avi', - 'mkv', - 'flv', - 'ogv', - 'mov', - '3gp', - '3g2', - ], - name: 'file-video-o', - }, - { extensions: ['doc', 'dot', 'docx', 'docm', 'dotx', 'dotm', 'docb'], name: 'file-word-o' }, - { - extensions: [ - 'xls', - 'xlt', - 'xlm', - 'xlsx', - 'xlsm', - 'xltx', - 'xltm', - 'xlsb', - 'xla', - 'xlam', - 'xll', - 'xlw', - ], - name: 'file-excel-o', - }, - { - extensions: [ - 'ppt', - 'pot', - 'pps', - 'pptx', - 'pptm', - 'potx', - 'potm', - 'ppam', - 'ppsx', - 'ppsm', - 'sldx', - 'sldm', - ], - name: 'file-powerpoint-o', - }, -]; - -export const getIconName = (type, path) => { - if (entryTypeIcons[type]) return entryTypeIcons[type]; - - const extension = path.split('.').pop(); - const file = fileTypeIcons.find(t => t.extensions.some(ext => ext === extension)); - - return file ? file.name : 'file-text-o'; -}; diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 8bebd16ace7..87c8aa541d8 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -5,6 +5,7 @@ import Cookies from 'js-cookie'; import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; import { sprintf, s__, __ } from './locale'; +import { fixTitle, hide } from '~/tooltips'; function Sidebar() { this.toggleTodo = this.toggleTodo.bind(this); @@ -42,13 +43,17 @@ Sidebar.prototype.addEventListeners = function() { Sidebar.prototype.sidebarToggleClicked = function(e, triggered) { const $this = $(this); - const isExpanded = $this.find('i').hasClass('fa-angle-double-right'); + const $collapseIcon = $('.js-sidebar-collapse'); + const $expandIcon = $('.js-sidebar-expand'); + const $toggleContainer = $('.js-sidebar-toggle-container'); + const isExpanded = $toggleContainer.data('is-expanded'); const tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar'); - const $allGutterToggleIcons = $('.js-sidebar-toggle i'); e.preventDefault(); if (isExpanded) { - $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left'); + $toggleContainer.data('is-expanded', false); + $collapseIcon.addClass('hidden'); + $expandIcon.removeClass('hidden'); $('aside.right-sidebar') .removeClass('right-sidebar-expanded') .addClass('right-sidebar-collapsed'); @@ -56,7 +61,9 @@ Sidebar.prototype.sidebarToggleClicked = function(e, triggered) { .removeClass('right-sidebar-expanded') .addClass('right-sidebar-collapsed'); } else { - $allGutterToggleIcons.removeClass('fa-angle-double-left').addClass('fa-angle-double-right'); + $toggleContainer.data('is-expanded', true); + $expandIcon.addClass('hidden'); + $collapseIcon.removeClass('hidden'); $('aside.right-sidebar') .removeClass('right-sidebar-collapsed') .addClass('right-sidebar-expanded'); @@ -77,7 +84,7 @@ Sidebar.prototype.toggleTodo = function(e) { const ajaxType = $this.data('deletePath') ? 'delete' : 'post'; const url = String($this.data('deletePath') || $this.data('createPath')); - $this.tooltip('hide'); + hide($this); $('.js-issuable-todo') .disable() @@ -119,7 +126,7 @@ Sidebar.prototype.todoUpdateDone = function(data) { .data('deletePath', deletePath); if ($el.hasClass('has-tooltip')) { - $el.tooltip('_fixTitle'); + fixTitle($el); } if (typeof $el.data('isCollapsed') !== 'undefined') { diff --git a/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue b/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue new file mode 100644 index 00000000000..b6e2dd46358 --- /dev/null +++ b/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue @@ -0,0 +1,100 @@ +<script> +import { mapState } from 'vuex'; +import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; +import { sprintf, s__ } from '~/locale'; + +export default { + name: 'DropdownFilter', + components: { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + }, + props: { + filterData: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['query']), + scope() { + return this.query.scope; + }, + supportedScopes() { + return Object.values(this.filterData.scopes); + }, + initialFilter() { + return this.query[this.filterData.filterParam]; + }, + filter() { + return this.initialFilter || this.filterData.filters.ANY.value; + }, + filtersArray() { + return this.filterData.filterByScope[this.scope]; + }, + selectedFilter: { + get() { + if (this.filtersArray.some(({ value }) => value === this.filter)) { + return this.filter; + } + + return this.filterData.filters.ANY.value; + }, + set(filter) { + visitUrl(setUrlParams({ [this.filterData.filterParam]: filter })); + }, + }, + selectedFilterText() { + const f = this.filtersArray.find(({ value }) => value === this.selectedFilter); + if (!f || f === this.filterData.filters.ANY) { + return sprintf(s__('Any %{header}'), { header: this.filterData.header }); + } + + return f.label; + }, + showDropdown() { + return this.supportedScopes.includes(this.scope); + }, + }, + methods: { + dropDownItemClass(filter) { + return { + 'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2': + filter === this.filterData.filters.ANY, + }; + }, + isFilterSelected(filter) { + return filter === this.selectedFilter; + }, + handleFilterChange(filter) { + this.selectedFilter = filter; + }, + }, +}; +</script> + +<template> + <gl-dropdown + v-if="showDropdown" + :text="selectedFilterText" + class="col-3 gl-pt-4 gl-pl-0 gl-pr-0 gl-mr-4" + menu-class="gl-w-full! gl-pl-0" + > + <header class="gl-text-center gl-font-weight-bold gl-font-lg"> + {{ filterData.header }} + </header> + <gl-dropdown-divider /> + <gl-dropdown-item + v-for="f in filtersArray" + :key="f.value" + :is-check-item="true" + :is-checked="isFilterSelected(f.value)" + :class="dropDownItemClass(f)" + @click="handleFilterChange(f.value)" + > + {{ f.label }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js b/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js new file mode 100644 index 00000000000..b29daca89cb --- /dev/null +++ b/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js @@ -0,0 +1,36 @@ +import { __ } from '~/locale'; + +const header = __('Confidentiality'); + +const filters = { + ANY: { + label: __('Any'), + value: null, + }, + CONFIDENTIAL: { + label: __('Confidential'), + value: 'yes', + }, + NOT_CONFIDENTIAL: { + label: __('Not confidential'), + value: 'no', + }, +}; + +const scopes = { + ISSUES: 'issues', +}; + +const filterByScope = { + [scopes.ISSUES]: [filters.ANY, filters.CONFIDENTIAL, filters.NOT_CONFIDENTIAL], +}; + +const filterParam = 'confidential'; + +export default { + header, + filters, + scopes, + filterByScope, + filterParam, +}; diff --git a/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js b/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js new file mode 100644 index 00000000000..0b93aa0be29 --- /dev/null +++ b/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js @@ -0,0 +1,42 @@ +import { __ } from '~/locale'; + +const header = __('Status'); + +const filters = { + ANY: { + label: __('Any'), + value: 'all', + }, + OPEN: { + label: __('Open'), + value: 'opened', + }, + CLOSED: { + label: __('Closed'), + value: 'closed', + }, + MERGED: { + label: __('Merged'), + value: 'merged', + }, +}; + +const scopes = { + ISSUES: 'issues', + MERGE_REQUESTS: 'merge_requests', +}; + +const filterByScope = { + [scopes.ISSUES]: [filters.ANY, filters.OPEN, filters.CLOSED], + [scopes.MERGE_REQUESTS]: [filters.ANY, filters.OPEN, filters.MERGED, filters.CLOSED], +}; + +const filterParam = 'state'; + +export default { + header, + filters, + scopes, + filterByScope, + filterParam, +}; diff --git a/app/assets/javascripts/search/dropdown_filter/index.js b/app/assets/javascripts/search/dropdown_filter/index.js new file mode 100644 index 00000000000..e5e0745d990 --- /dev/null +++ b/app/assets/javascripts/search/dropdown_filter/index.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import DropdownFilter from './components/dropdown_filter.vue'; +import stateFilterData from './constants/state_filter_data'; +import confidentialFilterData from './constants/confidential_filter_data'; + +Vue.use(Translate); + +const mountDropdownFilter = (store, { id, filterData }) => { + const el = document.getElementById(id); + + if (!el) return false; + + return new Vue({ + el, + store, + render(createElement) { + return createElement(DropdownFilter, { + props: { + filterData, + }, + }); + }, + }); +}; + +const dropdownFilters = [ + { + id: 'js-search-filter-by-state', + filterData: stateFilterData, + }, + { + id: 'js-search-filter-by-confidential', + filterData: confidentialFilterData, + }, +]; + +export default store => [...dropdownFilters].map(filter => mountDropdownFilter(store, filter)); diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js new file mode 100644 index 00000000000..780d3ff0d25 --- /dev/null +++ b/app/assets/javascripts/search/index.js @@ -0,0 +1,9 @@ +import { queryToObject } from '~/lib/utils/url_utility'; +import createStore from './store'; +import initDropdownFilters from './dropdown_filter'; + +export default () => { + const store = createStore({ query: queryToObject(window.location.search) }); + + initDropdownFilters(store); +}; diff --git a/app/assets/javascripts/search/state_filter/components/state_filter.vue b/app/assets/javascripts/search/state_filter/components/state_filter.vue deleted file mode 100644 index f08adaf8c83..00000000000 --- a/app/assets/javascripts/search/state_filter/components/state_filter.vue +++ /dev/null @@ -1,94 +0,0 @@ -<script> -import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; -import { - FILTER_STATES, - SCOPES, - FILTER_STATES_BY_SCOPE, - FILTER_HEADER, - FILTER_TEXT, -} from '../constants'; -import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; - -const FILTERS_ARRAY = Object.values(FILTER_STATES); - -export default { - name: 'StateFilter', - components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - }, - props: { - scope: { - type: String, - required: true, - }, - state: { - type: String, - required: false, - default: FILTER_STATES.ANY.value, - validator: v => FILTERS_ARRAY.some(({ value }) => value === v), - }, - }, - computed: { - selectedFilterText() { - const filter = FILTERS_ARRAY.find(({ value }) => value === this.selectedFilter); - if (!filter || filter === FILTER_STATES.ANY) { - return FILTER_TEXT; - } - - return filter.label; - }, - showDropdown() { - return Object.values(SCOPES).includes(this.scope); - }, - selectedFilter: { - get() { - if (FILTERS_ARRAY.some(({ value }) => value === this.state)) { - return this.state; - } - - return FILTER_STATES.ANY.value; - }, - set(state) { - visitUrl(setUrlParams({ state })); - }, - }, - }, - methods: { - dropDownItemClass(filter) { - return { - 'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2': - filter === FILTER_STATES.ANY, - }; - }, - isFilterSelected(filter) { - return filter === this.selectedFilter; - }, - handleFilterChange(state) { - this.selectedFilter = state; - }, - }, - filterStates: FILTER_STATES, - filterHeader: FILTER_HEADER, - filtersByScope: FILTER_STATES_BY_SCOPE, -}; -</script> - -<template> - <gl-dropdown v-if="showDropdown" :text="selectedFilterText" class="col-sm-3 gl-pt-4 gl-pl-0"> - <header class="gl-text-center gl-font-weight-bold gl-font-lg"> - {{ $options.filterHeader }} - </header> - <gl-dropdown-divider /> - <gl-dropdown-item - v-for="filter in $options.filtersByScope[scope]" - :key="filter.value" - :is-check-item="true" - :is-checked="isFilterSelected(filter.value)" - :class="dropDownItemClass(filter)" - @click="handleFilterChange(filter.value)" - >{{ filter.label }}</gl-dropdown-item - > - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/search/state_filter/constants.js b/app/assets/javascripts/search/state_filter/constants.js deleted file mode 100644 index 2f11cab9044..00000000000 --- a/app/assets/javascripts/search/state_filter/constants.js +++ /dev/null @@ -1,39 +0,0 @@ -import { __ } from '~/locale'; - -export const FILTER_HEADER = __('Status'); - -export const FILTER_TEXT = __('Any Status'); - -export const FILTER_STATES = { - ANY: { - label: __('Any'), - value: 'all', - }, - OPEN: { - label: __('Open'), - value: 'opened', - }, - CLOSED: { - label: __('Closed'), - value: 'closed', - }, - MERGED: { - label: __('Merged'), - value: 'merged', - }, -}; - -export const SCOPES = { - ISSUES: 'issues', - MERGE_REQUESTS: 'merge_requests', -}; - -export const FILTER_STATES_BY_SCOPE = { - [SCOPES.ISSUES]: [FILTER_STATES.ANY, FILTER_STATES.OPEN, FILTER_STATES.CLOSED], - [SCOPES.MERGE_REQUESTS]: [ - FILTER_STATES.ANY, - FILTER_STATES.OPEN, - FILTER_STATES.MERGED, - FILTER_STATES.CLOSED, - ], -}; diff --git a/app/assets/javascripts/search/state_filter/index.js b/app/assets/javascripts/search/state_filter/index.js deleted file mode 100644 index 13708574cfb..00000000000 --- a/app/assets/javascripts/search/state_filter/index.js +++ /dev/null @@ -1,34 +0,0 @@ -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import StateFilter from './components/state_filter.vue'; - -Vue.use(Translate); - -export default () => { - const el = document.getElementById('js-search-filter-by-state'); - - if (!el) return false; - - return new Vue({ - el, - components: { - StateFilter, - }, - data() { - const { dataset } = this.$options.el; - return { - scope: dataset.scope, - state: dataset.state, - }; - }, - - render(createElement) { - return createElement('state-filter', { - props: { - scope: this.scope, - state: this.state, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js new file mode 100644 index 00000000000..10cfb647a92 --- /dev/null +++ b/app/assets/javascripts/search/store/index.js @@ -0,0 +1,12 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; + +Vue.use(Vuex); + +export const getStoreConfig = ({ query }) => ({ + state: createState({ query }), +}); + +const createStore = config => new Vuex.Store(getStoreConfig(config)); +export default createStore; diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js new file mode 100644 index 00000000000..9115a613767 --- /dev/null +++ b/app/assets/javascripts/search/store/state.js @@ -0,0 +1,4 @@ +const createState = ({ query }) => ({ + query, +}); +export default createState; diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue index 1ccf5e9e032..6776a9ebb22 100644 --- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue +++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ import Vue from 'vue'; -import { GlFormGroup, GlDeprecatedButton, GlModal, GlToast, GlToggle } from '@gitlab/ui'; +import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import { __, s__, sprintf } from '~/locale'; import { visitUrl, getBaseURL } from '~/lib/utils/url_utility'; @@ -11,7 +11,7 @@ Vue.use(GlToast); export default { components: { GlFormGroup, - GlDeprecatedButton, + GlButton, GlModal, GlToggle, }, @@ -123,7 +123,7 @@ export default { <h4 class="js-section-header"> {{ s__('SelfMonitoring|Self monitoring') }} </h4> - <gl-deprecated-button class="js-settings-toggle">{{ __('Expand') }}</gl-deprecated-button> + <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> <p class="js-section-sub-header"> {{ s__('SelfMonitoring|Enable or disable instance self monitoring') }} </p> @@ -146,6 +146,7 @@ export default { :ok-title="__('Delete project')" :cancel-title="__('Cancel')" ok-variant="danger" + category="primary" @ok="deleteProject" @cancel="hideSelfMonitorModal" > diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js index bc3b2f16a6a..631d5448d1e 100644 --- a/app/assets/javascripts/sentry/sentry_config.js +++ b/app/assets/javascripts/sentry/sentry_config.js @@ -1,5 +1,5 @@ -import * as Sentry from '@sentry/browser'; import $ from 'jquery'; +import * as Sentry from '~/sentry/wrapper'; import { __ } from '~/locale'; const IGNORE_ERRORS = [ diff --git a/app/assets/javascripts/sentry/wrapper.js b/app/assets/javascripts/sentry/wrapper.js new file mode 100644 index 00000000000..24039e6141c --- /dev/null +++ b/app/assets/javascripts/sentry/wrapper.js @@ -0,0 +1,26 @@ +// Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179 +// export * from '@sentry/browser'; + +export function init(...args) { + return args; +} + +export function setUser(...args) { + return args; +} + +export function captureException(...args) { + return args; +} + +export function captureMessage(...args) { + return args; +} + +export function withScope(fn) { + fn({ + setTag(...args) { + return args; + }, + }); +} diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index e15549f5864..d662cc7b802 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -1,7 +1,6 @@ <script> -/* eslint-disable vue/no-v-html */ import { mapState, mapActions, mapGetters } from 'vuex'; -import { GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { GlLink, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; @@ -14,6 +13,9 @@ export default { GlLink, GlLoadingIcon, }, + directives: { + SafeHtml, + }, computed: { ...mapState(['installed', 'isLoading', 'hasFunctionData', 'helpPath', 'statusPath']), ...mapGetters(['getFunctions']), @@ -92,9 +94,9 @@ export default { }} </p> <ul> - <li v-html="noServerlessConfigFile"></li> - <li v-html="noGitlabYamlConfigured"></li> - <li v-html="mismatchedServerlessFunctions"></li> + <li v-safe-html="noServerlessConfigFile"></li> + <li v-safe-html="noGitlabYamlConfigured"></li> + <li v-safe-html="mismatchedServerlessFunctions"></li> <li>{{ s__('Serverless|The deploy job has not finished.') }}</li> </ul> diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue index 0d2c9f5151c..0b83d4b36eb 100644 --- a/app/assets/javascripts/serverless/components/missing_prometheus.vue +++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue @@ -1,11 +1,11 @@ <script> -import { GlDeprecatedButton, GlLink } from '@gitlab/ui'; +import { GlButton, GlLink } from '@gitlab/ui'; import { mapState } from 'vuex'; import { s__ } from '../../locale'; export default { components: { - GlDeprecatedButton, + GlButton, GlLink, }, props: { @@ -47,9 +47,9 @@ export default { </p> <div v-if="!missingData" class="text-left"> - <gl-deprecated-button :href="clustersPath" variant="success"> + <gl-button :href="clustersPath" variant="success" category="primary"> {{ s__('ServerlessDetails|Install Prometheus') }} - </gl-deprecated-button> + </gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue index d6de5e56a5c..79a1f39c7dd 100644 --- a/app/assets/javascripts/serverless/components/url.vue +++ b/app/assets/javascripts/serverless/components/url.vue @@ -16,7 +16,9 @@ export default { <template> <div class="clipboard-group"> - <div class="url-text-field label label-monospace monospace">{{ uri }}</div> + <div class="gl-cursor-text label label-monospace monospace" data-testid="url-text-field"> + {{ uri }} + </div> <clipboard-button :text="uri" :title="s__('ServerlessURL|Copy URL')" diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js index 9ee02f923d5..0ff84dc4667 100644 --- a/app/assets/javascripts/shared/milestones/form.js +++ b/app/assets/javascripts/shared/milestones/form.js @@ -16,6 +16,5 @@ export default (initGFM = true) => { milestones: initGFM, labels: initGFM, snippets: initGFM, - vulnerabilities: initGFM, }); }; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index 5c67e429383..20dc7cb07e7 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -1,11 +1,12 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { n__ } from '~/locale'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { n__, __ } from '~/locale'; export default { name: 'AssigneeTitle', components: { GlLoadingIcon, + GlIcon, }, props: { loading: { @@ -26,12 +27,19 @@ export default { required: false, default: false, }, + changing: { + type: Boolean, + required: true, + }, }, computed: { assigneeTitle() { const assignees = this.numberOfAssignees; return n__('Assignee', `%d Assignees`, assignees); }, + titleCopy() { + return this.changing ? __('Apply') : __('Edit'); + }, }, }; </script> @@ -43,11 +51,12 @@ export default { v-if="editable" class="js-sidebar-dropdown-toggle edit-link float-right" href="#" + data-test-id="edit-link" data-track-event="click_edit_button" data-track-label="right_sidebar" data-track-property="assignee" > - {{ __('Edit') }} + {{ titleCopy }} </a> <a v-if="showToggle" @@ -56,7 +65,7 @@ export default { href="#" role="button" > - <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i> + <gl-icon aria-hidden="true" data-hidden="true" name="chevron-double-lg-right" :size="12" /> </a> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 2f714ac3847..b9f268629fb 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -56,6 +56,9 @@ export default { // Note: Realtime is only available on issues right now, future support for MR wil be built later. return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue'; }, + relativeUrlRoot() { + return gon.relative_url_root ?? ''; + }, }, created() { this.removeAssignee = this.store.removeAssignee.bind(this.store); @@ -89,6 +92,8 @@ export default { .saveAssignees(this.field) .then(() => { this.loading = false; + this.store.resetChanging(); + refreshUserMergeRequestCounts(); }) .catch(() => { @@ -113,10 +118,11 @@ export default { :loading="loading || store.isFetching.assignees" :editable="store.editable" :show-toggle="!signedIn" + :changing="store.changing" /> <assignees v-if="!store.isFetching.assignees" - :root-path="store.rootPath" + :root-path="relativeUrlRoot" :users="store.assignees" :editable="store.editable" :issuable-type="issuableType" diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue index 86bfacbfb9e..46d51138ccf 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __ } from '~/locale'; import { deprecatedCreateFlash as Flash } from '~/flash'; @@ -8,7 +8,7 @@ import eventHub from '../../event_hub'; export default { components: { - GlLoadingIcon, + GlButton, }, props: { fullPath: { @@ -64,18 +64,18 @@ export default { <template> <div class="sidebar-item-warning-message-actions"> - <button type="button" class="btn btn-default gl-mr-3" @click="closeForm"> + <gl-button class="gl-mr-3" @click="closeForm"> {{ __('Cancel') }} - </button> - <button - type="button" - class="btn btn-close" - data-testid="confidential-toggle" + </gl-button> + <gl-button + category="secondary" + variant="warning" :disabled="isLoading" + :loading="isLoading" + data-testid="confidential-toggle" @click.prevent="submitForm" > - <gl-loading-icon v-if="isLoading" inline /> {{ toggleButtonText }} - </button> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue index d7be8927c29..1af1bc18e3e 100644 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue @@ -1,7 +1,6 @@ <script> import $ from 'jquery'; import { difference, union } from 'lodash'; -import { mapState, mapActions } from 'vuex'; import flash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -26,47 +25,49 @@ export default { 'projectIssuesPath', 'projectPath', ], - data: () => ({ - labelsSelectInProgress: false, - }), - computed: { - ...mapState(['selectedLabels']), - }, - mounted() { - this.setInitialState({ + data() { + return { + isLabelsSelectInProgress: false, selectedLabels: this.initiallySelectedLabels, - }); + }; }, methods: { - ...mapActions(['setInitialState', 'replaceSelectedLabels']), handleDropdownClose() { $(this.$el).trigger('hidden.gl.dropdown'); }, - handleUpdateSelectedLabels(labels) { + handleUpdateSelectedLabels(dropdownLabels) { const currentLabelIds = this.selectedLabels.map(label => label.id); - const userAddedLabelIds = labels.filter(label => label.set).map(label => label.id); - const userRemovedLabelIds = labels.filter(label => !label.set).map(label => label.id); + const userAddedLabelIds = dropdownLabels.filter(label => label.set).map(label => label.id); + const userRemovedLabelIds = dropdownLabels.filter(label => !label.set).map(label => label.id); - const issuableLabels = difference( - union(currentLabelIds, userAddedLabelIds), - userRemovedLabelIds, - ); + const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds); - this.labelsSelectInProgress = true; + this.updateSelectedLabels(labelIds); + }, + handleLabelRemove(labelId) { + const currentLabelIds = this.selectedLabels.map(label => label.id); + const labelIds = difference(currentLabelIds, [labelId]); + + this.updateSelectedLabels(labelIds); + }, + updateSelectedLabels(labelIds) { + this.isLabelsSelectInProgress = true; axios({ data: { [this.issuableType]: { - label_ids: issuableLabels, + label_ids: labelIds, }, }, method: 'put', url: this.labelsUpdatePath, }) - .then(({ data }) => this.replaceSelectedLabels(data.labels)) + .then(({ data }) => { + this.selectedLabels = data.labels; + }) .catch(() => flash(__('An error occurred while updating labels.'))) .finally(() => { - this.labelsSelectInProgress = false; + this.isLabelsSelectInProgress = false; }); }, }, @@ -76,6 +77,7 @@ export default { <template> <labels-select class="block labels js-labels-block" + :allow-label-remove="allowLabelEdit" :allow-label-create="allowLabelCreate" :allow-label-edit="allowLabelEdit" :allow-multiselect="true" @@ -86,10 +88,12 @@ export default { :labels-fetch-path="labelsFetchPath" :labels-filter-base-path="projectIssuesPath" :labels-manage-path="labelsManagePath" - :labels-select-in-progress="labelsSelectInProgress" + :labels-select-in-progress="isLabelsSelectInProgress" :selected-labels="selectedLabels" :variant="$options.sidebar" + data-qa-selector="labels_block" @onDropdownClose="handleDropdownClose" + @onLabelRemove="handleLabelRemove" @updateSelectedLabels="handleUpdateSelectedLabels" > {{ __('None') }} diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue index ea7230ae488..26a7c8e4a80 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __, sprintf } from '../../../locale'; import { deprecatedCreateFlash as Flash } from '~/flash'; @@ -8,7 +8,7 @@ import eventHub from '../../event_hub'; export default { components: { - GlLoadingIcon, + GlButton, }, inject: ['fullPath'], props: { @@ -65,19 +65,19 @@ export default { <template> <div class="sidebar-item-warning-message-actions"> - <button type="button" class="btn btn-default gl-mr-3" @click="closeForm"> + <gl-button class="gl-mr-3" @click="closeForm"> {{ __('Cancel') }} - </button> + </gl-button> - <button - type="button" + <gl-button data-testid="lock-toggle" - class="btn btn-close" + category="secondary" + variant="warning" :disabled="isLoading" + :loading="isLoading" @click.prevent="submitForm" > - <gl-loading-icon v-if="isLoading" inline /> {{ buttonText }} - </button> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue index 53ee7f46ad9..b96a2b93712 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -1,8 +1,7 @@ <script> import { mapGetters } from 'vuex'; -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; import eventHub from '~/sidebar/event_hub'; import editForm from './edit_form.vue'; @@ -26,7 +25,7 @@ export default { }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { @@ -79,13 +78,9 @@ export default { <template> <div class="block issuable-sidebar-item lock"> <div - v-tooltip - :title="tooltipLabel" + v-gl-tooltip.left.viewport="{ title: tooltipLabel }" class="sidebar-collapsed-icon" data-testid="sidebar-collapse-icon" - data-container="body" - data-placement="left" - data-boundary="viewport" @click="toggleForm" > <gl-icon :name="lockStatus.icon" class="sidebar-item-icon is-active" /> diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index e7dbc47aea1..c3a08f760a0 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -1,12 +1,11 @@ <script> -import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { userAvatarImage, @@ -87,12 +86,9 @@ export default { <div> <div v-if="showParticipantLabel" - v-tooltip + v-gl-tooltip.left.viewport :title="participantLabel" class="sidebar-collapsed-icon" - data-container="body" - data-placement="left" - data-boundary="viewport" @click="onClickCollapsedIcon" > <gl-icon name="users" /> diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue new file mode 100644 index 00000000000..6de926e0ff9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue @@ -0,0 +1,24 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import ReviewerAvatar from './reviewer_avatar.vue'; + +export default { + components: { + ReviewerAvatar, + }, + props: { + user: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <button type="button" class="btn-link"> + <reviewer-avatar :user="user" :img-size="24" /> + <span class="author"> {{ user.name }} </span> + </button> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue new file mode 100644 index 00000000000..45707c18f7b --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue @@ -0,0 +1,107 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import CollapsedReviewer from './collapsed_reviewer.vue'; + +const DEFAULT_MAX_COUNTER = 99; +const DEFAULT_RENDER_COUNT = 5; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + CollapsedReviewer, + GlIcon, + }, + props: { + users: { + type: Array, + required: true, + }, + }, + computed: { + hasNoUsers() { + return !this.users.length; + }, + hasMoreThanOneReviewer() { + return this.users.length > 1; + }, + hasMoreThanTwoReviewers() { + return this.users.length > 2; + }, + allReviewersCanMerge() { + return this.users.every(user => user.can_merge); + }, + sidebarAvatarCounter() { + if (this.users.length > DEFAULT_MAX_COUNTER) { + return `${DEFAULT_MAX_COUNTER}+`; + } + + return `+${this.users.length - 1}`; + }, + collapsedUsers() { + const collapsedLength = this.hasMoreThanTwoReviewers ? 1 : this.users.length; + + return this.users.slice(0, collapsedLength); + }, + tooltipTitleMergeStatus() { + const mergeLength = this.users.filter(u => u.can_merge).length; + + if (mergeLength === this.users.length) { + return ''; + } else if (mergeLength > 0) { + return sprintf(__('%{mergeLength}/%{usersLength} can merge'), { + mergeLength, + usersLength: this.users.length, + }); + } + + return this.users.length === 1 ? __('cannot merge') : __('no one can merge'); + }, + tooltipTitle() { + const maxRender = Math.min(DEFAULT_RENDER_COUNT, this.users.length); + const renderUsers = this.users.slice(0, maxRender); + const names = renderUsers.map(u => u.name); + + if (!this.users.length) { + return __('Reviewer(s)'); + } + + if (this.users.length > names.length) { + names.push(sprintf(__('+ %{amount} more'), { amount: this.users.length - names.length })); + } + + const text = names.join(', '); + + return this.tooltipTitleMergeStatus ? `${text} (${this.tooltipTitleMergeStatus})` : text; + }, + + tooltipOptions() { + return { container: 'body', placement: 'left', boundary: 'viewport' }; + }, + }, +}; +</script> + +<template> + <div + v-gl-tooltip="tooltipOptions" + :class="{ 'multiple-users': hasMoreThanOneReviewer }" + :title="tooltipTitle" + class="sidebar-collapsed-icon sidebar-collapsed-user" + > + <gl-icon v-if="hasNoUsers" name="user" :aria-label="__('None')" /> + <collapsed-reviewer v-for="user in collapsedUsers" :key="user.id" :user="user" /> + <button v-if="hasMoreThanTwoReviewers" class="btn-link" type="button"> + <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span> + <i + v-if="!allReviewersCanMerge" + aria-hidden="true" + class="fa fa-exclamation-triangle merge-icon" + ></i> + </button> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue new file mode 100644 index 00000000000..9fa3fa38eac --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue @@ -0,0 +1,43 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { __, sprintf } from '~/locale'; + +export default { + props: { + user: { + type: Object, + required: true, + }, + imgSize: { + type: Number, + required: true, + }, + }, + computed: { + reviewerAlt() { + return sprintf(__("%{userName}'s avatar"), { userName: this.user.name }); + }, + avatarUrl() { + return this.user.avatar || this.user.avatar_url || gon.default_avatar_url; + }, + hasMergeIcon() { + return !this.user.can_merge; + }, + }, +}; +</script> + +<template> + <span class="position-relative"> + <img + :alt="reviewerAlt" + :src="avatarUrl" + :width="imgSize" + :class="`s${imgSize}`" + class="avatar avatar-inline m-0" + data-qa-selector="avatar_image" + /> + <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i> + </span> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue new file mode 100644 index 00000000000..b1b04564a62 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue @@ -0,0 +1,84 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import ReviewerAvatar from './reviewer_avatar.vue'; + +export default { + components: { + ReviewerAvatar, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + user: { + type: Object, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + tooltipPlacement: { + type: String, + default: 'bottom', + required: false, + }, + tooltipHasName: { + type: Boolean, + default: true, + required: false, + }, + issuableType: { + type: String, + default: 'issue', + required: false, + }, + }, + computed: { + cannotMerge() { + return this.issuableType === 'merge_request' && !this.user.can_merge; + }, + tooltipTitle() { + if (this.cannotMerge && this.tooltipHasName) { + return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name }); + } else if (this.cannotMerge) { + return __('Cannot merge'); + } else if (this.tooltipHasName) { + return this.user.name; + } + + return ''; + }, + tooltipOption() { + return { + container: 'body', + placement: this.tooltipPlacement, + boundary: 'viewport', + }; + }, + reviewerUrl() { + return this.user.web_url; + }, + }, +}; +</script> + +<template> + <!-- must be `d-inline-block` or parent flex-basis causes width issues --> + <gl-link + v-gl-tooltip="tooltipOption" + :href="reviewerUrl" + :title="tooltipTitle" + class="d-inline-block" + > + <!-- use d-flex so that slot can be appropriately styled --> + <span class="d-flex"> + <reviewer-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> + <slot :user="user"></slot> + </span> + </gl-link> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue new file mode 100644 index 00000000000..4f4f7002dc9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue @@ -0,0 +1,65 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { n__ } from '~/locale'; + +export default { + name: 'ReviewerTitle', + components: { + GlLoadingIcon, + GlIcon, + }, + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + numberOfReviewers: { + type: Number, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + showToggle: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + reviewerTitle() { + const reviewers = this.numberOfReviewers; + return n__('Reviewer', `%d Reviewers`, reviewers); + }, + }, +}; +</script> +<template> + <div class="title hide-collapsed"> + {{ reviewerTitle }} + <gl-loading-icon v-if="loading" inline class="align-bottom" /> + <a + v-if="editable" + class="js-sidebar-dropdown-toggle edit-link float-right" + href="#" + data-track-event="click_edit_button" + data-track-label="right_sidebar" + data-track-property="reviewer" + > + {{ __('Edit') }} + </a> + <a + v-if="showToggle" + :aria-label="__('Toggle sidebar')" + class="gutter-toggle float-right js-sidebar-toggle" + href="#" + role="button" + > + <gl-icon aria-hidden="true" data-hidden="true" name="chevron-double-lg-right" :size="12" /> + </a> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue new file mode 100644 index 00000000000..6a3d88f6385 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -0,0 +1,72 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import CollapsedReviewerList from './collapsed_reviewer_list.vue'; +import UncollapsedReviewerList from './uncollapsed_reviewer_list.vue'; + +export default { + // name: 'Reviewers' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Reviewers', + components: { + CollapsedReviewerList, + UncollapsedReviewerList, + }, + props: { + rootPath: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + computed: { + hasNoUsers() { + return !this.users.length; + }, + sortedReviewers() { + const canMergeUsers = this.users.filter(user => user.can_merge); + const canNotMergeUsers = this.users.filter(user => !user.can_merge); + + return [...canMergeUsers, ...canNotMergeUsers]; + }, + }, + methods: { + assignSelf() { + this.$emit('assign-self'); + }, + }, +}; +</script> + +<template> + <div> + <collapsed-reviewer-list :users="sortedReviewers" :issuable-type="issuableType" /> + + <div class="value hide-collapsed"> + <template v-if="hasNoUsers"> + <span class="assign-yourself no-value qa-assign-yourself"> + {{ __('None') }} + </span> + </template> + + <uncollapsed-reviewer-list + v-else + :users="sortedReviewers" + :root-path="rootPath" + :issuable-type="issuableType" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue new file mode 100644 index 00000000000..aee94a55134 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -0,0 +1,112 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { deprecatedCreateFlash as Flash } from '~/flash'; +import eventHub from '~/sidebar/event_hub'; +import Store from '~/sidebar/stores/sidebar_store'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ReviewerTitle from './reviewer_title.vue'; +import Reviewers from './reviewers.vue'; +import { __ } from '~/locale'; + +export default { + name: 'SidebarReviewers', + components: { + ReviewerTitle, + Reviewers, + }, + mixins: [glFeatureFlagsMixin()], + props: { + mediator: { + type: Object, + required: true, + }, + field: { + type: String, + required: true, + }, + signedIn: { + type: Boolean, + required: false, + default: false, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + issuableIid: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + store: new Store(), + loading: false, + }; + }, + computed: { + relativeUrlRoot() { + return gon.relative_url_root ?? ''; + }, + }, + created() { + this.removeReviewer = this.store.removeReviewer.bind(this.store); + this.addReviewer = this.store.addReviewer.bind(this.store); + this.removeAllReviewers = this.store.removeAllReviewers.bind(this.store); + + // Get events from deprecatedJQueryDropdown + eventHub.$on('sidebar.removeReviewer', this.removeReviewer); + eventHub.$on('sidebar.addReviewer', this.addReviewer); + eventHub.$on('sidebar.removeAllReviewers', this.removeAllReviewers); + eventHub.$on('sidebar.saveReviewers', this.saveReviewers); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeReviewer', this.removeReviewer); + eventHub.$off('sidebar.addReviewer', this.addReviewer); + eventHub.$off('sidebar.removeAllReviewers', this.removeAllReviewers); + eventHub.$off('sidebar.saveReviewers', this.saveReviewers); + }, + methods: { + saveReviewers() { + this.loading = true; + + this.mediator + .saveReviewers(this.field) + .then(() => { + this.loading = false; + // Uncomment once this issue has been addressed > https://gitlab.com/gitlab-org/gitlab/-/issues/237922 + // refreshUserMergeRequestCounts(); + }) + .catch(() => { + this.loading = false; + return new Flash(__('Error occurred when saving reviewers')); + }); + }, + }, +}; +</script> + +<template> + <div> + <reviewer-title + :number-of-reviewers="store.reviewers.length" + :loading="loading || store.isFetching.reviewers" + :editable="store.editable" + :show-toggle="!signedIn" + /> + <reviewers + v-if="!store.isFetching.reviewers" + :root-path="relativeUrlRoot" + :users="store.reviewers" + :editable="store.editable" + :issuable-type="issuableType" + class="value" + /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue new file mode 100644 index 00000000000..e82a271d007 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -0,0 +1,103 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { __, sprintf } from '~/locale'; +import ReviewerAvatarLink from './reviewer_avatar_link.vue'; + +const DEFAULT_RENDER_COUNT = 5; + +export default { + components: { + ReviewerAvatarLink, + }, + props: { + users: { + type: Array, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + data() { + return { + showLess: true, + }; + }, + computed: { + firstUser() { + return this.users[0]; + }, + hasOneUser() { + return this.users.length === 1; + }, + hiddenReviewersLabel() { + const { numberOfHiddenReviewers } = this; + return sprintf(__('+ %{numberOfHiddenReviewers} more'), { numberOfHiddenReviewers }); + }, + renderShowMoreSection() { + return this.users.length > DEFAULT_RENDER_COUNT; + }, + numberOfHiddenReviewers() { + return this.users.length - DEFAULT_RENDER_COUNT; + }, + uncollapsedUsers() { + const uncollapsedLength = this.showLess + ? Math.min(this.users.length, DEFAULT_RENDER_COUNT) + : this.users.length; + return this.showLess ? this.users.slice(0, uncollapsedLength) : this.users; + }, + username() { + return `@${this.firstUser.username}`; + }, + }, + methods: { + toggleShowLess() { + this.showLess = !this.showLess; + }, + }, +}; +</script> + +<template> + <reviewer-avatar-link + v-if="hasOneUser" + #default="{ user }" + tooltip-placement="left" + :tooltip-has-name="false" + :user="firstUser" + :root-path="rootPath" + :issuable-type="issuableType" + > + <div class="gl-ml-3 gl-line-height-normal"> + <div class="author">{{ user.name }}</div> + <div class="username">{{ username }}</div> + </div> + </reviewer-avatar-link> + <div v-else> + <div class="user-list"> + <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item"> + <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType" /> + </div> + </div> + <div v-if="renderShowMoreSection" class="user-list-more"> + <button + type="button" + class="btn-link" + data-qa-selector="more_reviewers_link" + @click="toggleShowLess" + > + <template v-if="showLess"> + {{ hiddenReviewersLabel }} + </template> + <template v-else>{{ __('- show less') }}</template> + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue index bc2319c0f36..9d72bf4394e 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue @@ -1,7 +1,6 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; export default { name: 'TimeTrackingCollapsedState', @@ -9,7 +8,7 @@ export default { GlIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { showComparisonState: { @@ -97,14 +96,7 @@ export default { </script> <template> - <div - v-tooltip - :title="tooltipText" - class="sidebar-collapsed-icon" - data-container="body" - data-placement="left" - data-boundary="viewport" - > + <div v-gl-tooltip:body.viewport.left :title="tooltipText" class="sidebar-collapsed-icon"> <gl-icon name="timer" /> <div class="time-tracking-collapsed-summary"> <div :class="divClass"> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue index 4cb8d9ebd62..d4cc98e3743 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue @@ -1,7 +1,6 @@ <script> -import { GlProgressBar } from '@gitlab/ui'; +import { GlProgressBar, GlTooltipDirective } from '@gitlab/ui'; import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; -import tooltip from '../../../vue_shared/directives/tooltip'; import { s__, sprintf } from '~/locale'; export default { @@ -10,7 +9,7 @@ export default { GlProgressBar, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { timeSpent: { @@ -73,7 +72,7 @@ export default { <template> <div class="time-tracking-comparison-pane"> <div - v-tooltip + v-gl-tooltip :title="timeRemainingTooltip" :class="timeRemainingStatusClass" class="compare-meter" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue index 05ad7b4ea3e..406677941b7 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue @@ -26,11 +26,14 @@ export default { methods: { listenForQuickActions() { $(document).on('ajax:success', '.gfm-form', this.quickActionListened); + eventHub.$on('timeTrackingUpdated', data => { - this.quickActionListened(null, data); + this.quickActionListened({ detail: [data] }); }); }, - quickActionListened(e, data) { + quickActionListened(e) { + const data = e.detail[0]; + const subscribedCommands = ['spend_time', 'time_estimate']; let changedCommands; if (data !== undefined) { diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index be559b16420..00b4e2de5e5 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -1,10 +1,10 @@ import $ from 'jquery'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import Vuex from 'vuex'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import SidebarLabels from './components/labels/sidebar_labels.vue'; +import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import SidebarMoveIssue from './lib/sidebar_move_issue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue'; @@ -13,17 +13,15 @@ import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptio import SidebarSeverity from './components/severity/sidebar_severity.vue'; import Translate from '../vue_shared/translate'; import createDefaultClient from '~/lib/graphql'; -import { store } from '~/notes/stores'; -import { isInIssuePage, parseBoolean } from '~/lib/utils/common_utils'; -import mergeRequestStore from '~/mr_notes/stores'; -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; +import { isInIssuePage, isInIncidentPage, parseBoolean } from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; Vue.use(Translate); Vue.use(VueApollo); -Vue.use(Vuex); -function getSidebarOptions() { - return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); +function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-options')) { + return JSON.parse(sidebarOptEl.innerHTML); } function mountAssigneesComponent(mediator) { @@ -50,6 +48,36 @@ function mountAssigneesComponent(mediator) { projectPath: fullPath, field: el.dataset.field, signedIn: el.hasAttribute('data-signed-in'), + issuableType: isInIssuePage() || isInIncidentPage() ? 'issue' : 'merge_request', + }, + }), + }); +} + +function mountReviewersComponent(mediator) { + const el = document.getElementById('js-vue-sidebar-reviewers'); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + if (!el) return; + + const { iid, fullPath } = getSidebarOptions(); + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + components: { + SidebarReviewers, + }, + render: createElement => + createElement('sidebar-reviewers', { + props: { + mediator, + issuableIid: String(iid), + projectPath: fullPath, + field: el.dataset.field, + signedIn: el.hasAttribute('data-signed-in'), issuableType: isInIssuePage() ? 'issue' : 'merge_request', }, }), @@ -63,8 +91,6 @@ export function mountSidebarLabels() { return false; } - const labelsStore = new Vuex.Store(labelsSelectModule()); - return new Vue({ el, provide: { @@ -74,7 +100,6 @@ export function mountSidebarLabels() { allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels), initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels), }, - store: labelsStore, render: createElement => createElement(SidebarLabels), }); } @@ -89,47 +114,74 @@ function mountConfidentialComponent(mediator) { const dataNode = document.getElementById('js-confidential-issue-data'); const initialData = JSON.parse(dataNode.innerHTML); - // eslint-disable-next-line no-new - new Vue({ - el, - store, - components: { - ConfidentialIssueSidebar, - }, - render: createElement => - createElement('confidential-issue-sidebar', { - props: { - iid: String(iid), - fullPath, - isEditable: initialData.is_editable, - service: mediator.service, - }, - }), - }); + import(/* webpackChunkName: 'notesStore' */ '~/notes/stores') + .then( + ({ store }) => + new Vue({ + el, + store, + components: { + ConfidentialIssueSidebar, + }, + render: createElement => + createElement('confidential-issue-sidebar', { + props: { + iid: String(iid), + fullPath, + isEditable: initialData.is_editable, + service: mediator.service, + }, + }), + }), + ) + .catch(() => { + createFlash({ message: __('Failed to load sidebar confidential toggle') }); + }); } function mountLockComponent() { const el = document.getElementById('js-lock-entry-point'); + + if (!el) { + return; + } + const { fullPath } = getSidebarOptions(); const dataNode = document.getElementById('js-lock-issue-data'); const initialData = JSON.parse(dataNode.innerHTML); - return el - ? new Vue({ - el, - store: isInIssuePage() ? store : mergeRequestStore, - provide: { - fullPath, - }, - render: createElement => - createElement(IssuableLockForm, { - props: { - isEditable: initialData.is_editable, - }, - }), - }) - : undefined; + let importStore; + if (isInIssuePage() || isInIncidentPage()) { + importStore = import(/* webpackChunkName: 'notesStore' */ '~/notes/stores').then( + ({ store }) => store, + ); + } else { + importStore = import(/* webpackChunkName: 'mrNotesStore' */ '~/mr_notes/stores').then( + store => store.default, + ); + } + + importStore + .then( + store => + new Vue({ + el, + store, + provide: { + fullPath, + }, + render: createElement => + createElement(IssuableLockForm, { + props: { + isEditable: initialData.is_editable, + }, + }), + }), + ) + .catch(() => { + createFlash({ message: __('Failed to load sidebar lock status') }); + }); } function mountParticipantsComponent(mediator) { @@ -218,8 +270,9 @@ function mountSeverityComponent() { export function mountSidebar(mediator) { mountAssigneesComponent(mediator); + mountReviewersComponent(mediator); mountConfidentialComponent(mediator); - mountLockComponent(mediator); + mountLockComponent(); mountParticipantsComponent(mediator); mountSubscriptionsComponent(mediator); diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 8f1f76a2e02..2146fb83b13 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -40,6 +40,17 @@ export default class SidebarMediator { return this.service.update(field, data); } + saveReviewers(field) { + const selected = this.store.reviewers.map(u => u.id); + + // If there are no ids, that means we have to unassign (which is id = 0) + // And it only accepts an array, hence [0] + const reviewers = selected.length === 0 ? [0] : selected; + const data = { reviewer_ids: reviewers }; + + return this.service.update(field, data); + } + setMoveToProjectId(projectId) { this.store.setMoveToProjectId(projectId); } @@ -55,6 +66,7 @@ export default class SidebarMediator { processFetchedData(data) { this.store.setAssigneeData(data); + this.store.setReviewerData(data); this.store.setTimeTrackingData(data); this.store.setParticipantsData(data); this.store.setSubscriptionsData(data); diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 095f93b72a9..d53393052eb 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -18,8 +18,10 @@ export default class SidebarStore { this.humanTimeSpent = ''; this.timeTrackingLimitToHours = timeTrackingLimitToHours; this.assignees = []; + this.reviewers = []; this.isFetching = { assignees: true, + reviewers: true, participants: true, subscriptions: true, }; @@ -31,17 +33,29 @@ export default class SidebarStore { this.projectEmailsDisabled = false; this.subscribeDisabledDescription = ''; this.subscribed = null; + this.changing = false; SidebarStore.singleton = this; } - setAssigneeData(data) { + setAssigneeData({ assignees }) { this.isFetching.assignees = false; - if (data.assignees) { - this.assignees = data.assignees; + if (assignees) { + this.assignees = assignees; } } + setReviewerData({ reviewers }) { + this.isFetching.reviewers = false; + if (reviewers) { + this.reviewers = reviewers; + } + } + + resetChanging() { + this.changing = false; + } + setTimeTrackingData(data) { this.timeEstimate = data.time_estimate; this.totalTimeSpent = data.total_time_spent; @@ -71,24 +85,47 @@ export default class SidebarStore { addAssignee(assignee) { if (!this.findAssignee(assignee)) { + this.changing = true; this.assignees.push(assignee); } } + addReviewer(reviewer) { + if (!this.findReviewer(reviewer)) { + this.reviewers.push(reviewer); + } + } + findAssignee(findAssignee) { - return this.assignees.find(assignee => assignee.id === findAssignee.id); + return this.assignees.find(({ id }) => id === findAssignee.id); } - removeAssignee(removeAssignee) { - if (removeAssignee) { - this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); + findReviewer(findReviewer) { + return this.reviewers.find(({ id }) => id === findReviewer.id); + } + + removeAssignee(assignee) { + if (assignee) { + this.changing = true; + this.assignees = this.assignees.filter(({ id }) => id !== assignee.id); + } + } + + removeReviewer(reviewer) { + if (reviewer) { + this.reviewers = this.reviewers.filter(({ id }) => id !== reviewer.id); } } removeAllAssignees() { + this.changing = true; this.assignees = []; } + removeAllReviewers() { + this.reviewers = []; + } + setAssigneesFromRealtime(data) { this.assignees = data; } diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 586d1e62c2f..5fa6cef7195 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -57,16 +57,10 @@ export default class SingleFileDiff { this.content.hide(); this.$toggleIcon.addClass('fa-caret-right').removeClass('fa-caret-down'); this.collapsedContent.show(); - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - gl.diffNotesCompileComponents(); - } } else if (this.content) { this.collapsedContent.hide(); this.content.show(); this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right'); - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - gl.diffNotesCompileComponents(); - } } else { this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right'); return this.getContentHTML(cb); @@ -90,10 +84,6 @@ export default class SingleFileDiff { } this.collapsedContent.after(this.content); - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - gl.diffNotesCompileComponents(); - } - const $file = $(this.file); FilesCommentButton.init($file); diff --git a/app/assets/javascripts/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js deleted file mode 100644 index 76a1f6d1458..00000000000 --- a/app/assets/javascripts/snippet/snippet_bundle.js +++ /dev/null @@ -1,30 +0,0 @@ -import { initEditorLite } from '~/blob/utils'; -import setupCollapsibleInputs from './collapsible_input'; - -let editor; - -const initMonaco = () => { - const editorEl = document.getElementById('editor'); - const contentEl = document.querySelector('.snippet-file-content'); - const fileNameEl = document.querySelector('.js-snippet-file-name'); - const form = document.querySelector('.snippet-form-holder form'); - - editor = initEditorLite({ - el: editorEl, - blobPath: fileNameEl.value, - blobContent: contentEl.value, - }); - - fileNameEl.addEventListener('change', () => { - editor.updateModelLanguage(fileNameEl.value); - }); - - form.addEventListener('submit', () => { - contentEl.value = editor.getValue(); - }); -}; - -export default () => { - initMonaco(); - setupCollapsibleInputs(); -}; diff --git a/app/assets/javascripts/snippet/snippet_edit.js b/app/assets/javascripts/snippet/snippet_edit.js index 3dc74922a77..88677ddd15f 100644 --- a/app/assets/javascripts/snippet/snippet_edit.js +++ b/app/assets/javascripts/snippet/snippet_edit.js @@ -1,33 +1,6 @@ -import $ from 'jquery'; -import initSnippet from '~/snippet/snippet_bundle'; import ZenMode from '~/zen_mode'; -import GLForm from '~/gl_form'; -import { SnippetEditInit } from '~/snippets'; +import SnippetsEdit from '~/snippets/components/edit.vue'; +import SnippetsAppFactory from '~/snippets'; -document.addEventListener('DOMContentLoaded', () => { - const form = document.querySelector('.snippet-form'); - const personalSnippetOptions = { - members: false, - issues: false, - mergeRequests: false, - epics: false, - milestones: false, - labels: false, - snippets: false, - vulnerabilities: false, - }; - const projectSnippetOptions = {}; - - const options = - form.dataset.snippetType === 'project' || form.dataset.projectPath - ? projectSnippetOptions - : personalSnippetOptions; - - if (gon?.features?.snippetsEditVue) { - SnippetEditInit(); - } else { - initSnippet(); - new GLForm($(form), options); // eslint-disable-line no-new - } - new ZenMode(); // eslint-disable-line no-new -}); +SnippetsAppFactory(document.getElementById('js-snippet-edit'), SnippetsEdit); +new ZenMode(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/snippet/snippet_embed.js b/app/assets/javascripts/snippet/snippet_embed.js deleted file mode 100644 index 65dd62f6af9..00000000000 --- a/app/assets/javascripts/snippet/snippet_embed.js +++ /dev/null @@ -1,37 +0,0 @@ -import { __ } from '~/locale'; -import { parseUrlPathname, parseUrl } from '../lib/utils/common_utils'; - -function swapActiveState(activateBtn, deactivateBtn) { - activateBtn.classList.add('is-active'); - deactivateBtn.classList.remove('is-active'); -} - -export default () => { - const shareBtn = document.querySelector('.js-share-btn'); - - if (shareBtn) { - const embedBtn = document.querySelector('.js-embed-btn'); - const snippetUrlArea = document.querySelector('.js-snippet-url-area'); - const embedAction = document.querySelector('.js-embed-action'); - const dataUrl = snippetUrlArea.getAttribute('data-url'); - - snippetUrlArea.addEventListener('click', () => snippetUrlArea.select()); - - shareBtn.addEventListener('click', () => { - swapActiveState(shareBtn, embedBtn); - snippetUrlArea.value = dataUrl; - embedAction.innerText = __('Share'); - }); - - embedBtn.addEventListener('click', () => { - const parser = parseUrl(dataUrl); - const url = `${parser.origin + parseUrlPathname(dataUrl)}`; - const params = parser.search; - const scriptTag = `<script src="${url}.js${params}"></script>`; - - swapActiveState(embedBtn, shareBtn); - snippetUrlArea.value = scriptTag; - embedAction.innerText = __('Embed'); - }); - } -}; diff --git a/app/assets/javascripts/snippet/snippet_show.js b/app/assets/javascripts/snippet/snippet_show.js index bbddfc579c5..caa76fc9988 100644 --- a/app/assets/javascripts/snippet/snippet_show.js +++ b/app/assets/javascripts/snippet/snippet_show.js @@ -1,21 +1,13 @@ -import LineHighlighter from '~/line_highlighter'; -import BlobViewer from '~/blob/viewer'; -import ZenMode from '~/zen_mode'; import initNotes from '~/init_notes'; -import snippetEmbed from '~/snippet/snippet_embed'; -import { SnippetShowInit } from '~/snippets'; import loadAwardsHandler from '~/awards_handler'; +import SnippetsShow from '~/snippets/components/show.vue'; +import SnippetsAppFactory from '~/snippets'; +import ZenMode from '~/zen_mode'; + +SnippetsAppFactory(document.getElementById('js-snippet-view'), SnippetsShow); + +initNotes(); +loadAwardsHandler(); -document.addEventListener('DOMContentLoaded', () => { - if (!gon.features.snippetsVue) { - new LineHighlighter(); // eslint-disable-line no-new - new BlobViewer(); // eslint-disable-line no-new - initNotes(); - new ZenMode(); // eslint-disable-line no-new - snippetEmbed(); - } else { - SnippetShowInit(); - initNotes(); - } - loadAwardsHandler(); -}); +// eslint-disable-next-line no-new +new ZenMode(); diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index 1a539aa0876..dd77d49803f 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -6,7 +6,12 @@ import { __, sprintf } from '~/locale'; import TitleField from '~/vue_shared/components/form/title.vue'; import { redirectTo, joinPaths } from '~/lib/utils/url_utility'; import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; -import { SNIPPET_MARK_EDIT_APP_START } from '~/performance_constants'; +import { + SNIPPET_MARK_EDIT_APP_START, + SNIPPET_MEASURE_BLOBS_CONTENT, +} from '~/performance_constants'; +import eventHub from '~/blob/components/eventhub'; +import { performanceMarkAndMeasure } from '~/performance_utils'; import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql'; import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql'; @@ -17,11 +22,14 @@ import { SNIPPET_VISIBILITY_PRIVATE, } from '../constants'; import defaultVisibilityQuery from '../queries/snippet_visibility.query.graphql'; +import { markBlobPerformance } from '../utils/blob'; import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue'; import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; import SnippetDescriptionEdit from './snippet_description_edit.vue'; +eventHub.$on(SNIPPET_MEASURE_BLOBS_CONTENT, markBlobPerformance); + export default { components: { SnippetDescriptionEdit, @@ -104,12 +112,6 @@ export default { } return this.snippet.webUrl; }, - titleFieldId() { - return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_title`; - }, - descriptionFieldId() { - return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`; - }, newSnippetSchema() { return { title: '', @@ -119,7 +121,7 @@ export default { }, }, beforeCreate() { - performance.mark(SNIPPET_MARK_EDIT_APP_START); + performanceMarkAndMeasure({ mark: SNIPPET_MARK_EDIT_APP_START }); }, created() { window.addEventListener('beforeunload', this.onBeforeUnload); @@ -151,7 +153,7 @@ export default { this.newSnippet = false; }, onSnippetFetch(snippetRes) { - if (snippetRes.data.snippets.edges.length === 0) { + if (snippetRes.data.snippets.nodes.length === 0) { this.onNewSnippetFetched(); } else { this.onExistingSnippetFetched(); @@ -220,14 +222,13 @@ export default { /> <template v-else> <title-field - :id="titleFieldId" + id="snippet-title" v-model="snippet.title" data-qa-selector="snippet_title_field" required :autofocus="true" /> <snippet-description-edit - :id="descriptionFieldId" v-model="snippet.description" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" diff --git a/app/assets/javascripts/snippets/components/embed_dropdown.vue b/app/assets/javascripts/snippets/components/embed_dropdown.vue index 589754a8b19..a5d2c337d67 100644 --- a/app/assets/javascripts/snippets/components/embed_dropdown.vue +++ b/app/assets/javascripts/snippets/components/embed_dropdown.vue @@ -60,7 +60,7 @@ export default { class="gl-dropdown-text-py-0 gl-dropdown-text-block" data-testid="input" > - <gl-form-input-group :value="value" readonly select-on-click> + <gl-form-input-group :value="value" readonly select-on-click :aria-label="name"> <template #append> <gl-button v-gl-tooltip.hover diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue index 43be2cb7ed8..4a2f060ff7c 100644 --- a/app/assets/javascripts/snippets/components/show.vue +++ b/app/assets/javascripts/snippets/components/show.vue @@ -5,11 +5,18 @@ import SnippetHeader from './snippet_header.vue'; import SnippetTitle from './snippet_title.vue'; import SnippetBlob from './snippet_blob_view.vue'; import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; +import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; +import { + SNIPPET_MARK_VIEW_APP_START, + SNIPPET_MEASURE_BLOBS_CONTENT, +} from '~/performance_constants'; +import { performanceMarkAndMeasure } from '~/performance_utils'; +import eventHub from '~/blob/components/eventhub'; import { getSnippetMixin } from '../mixins/snippets'; -import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; +import { markBlobPerformance } from '../utils/blob'; -import { SNIPPET_MARK_VIEW_APP_START } from '~/performance_constants'; +eventHub.$on(SNIPPET_MEASURE_BLOBS_CONTENT, markBlobPerformance); export default { components: { @@ -30,7 +37,7 @@ export default { }, }, beforeCreate() { - performance.mark(SNIPPET_MARK_VIEW_APP_START); + performanceMarkAndMeasure({ mark: SNIPPET_MARK_VIEW_APP_START }); }, }; </script> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue index 55cd13a6930..ab2553265a2 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue @@ -2,7 +2,6 @@ import { GlButton } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import { s__, sprintf } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import SnippetBlobEdit from './snippet_blob_edit.vue'; import { SNIPPET_MAX_BLOBS } from '../constants'; import { createBlob, decorateBlob, diffAll } from '../utils/blob'; @@ -12,7 +11,6 @@ export default { SnippetBlobEdit, GlButton, }, - mixins: [glFeatureFlagsMixin()], props: { initBlobs: { type: Array, @@ -52,12 +50,6 @@ export default { canAdd() { return this.count < SNIPPET_MAX_BLOBS; }, - hasMultiFilesEnabled() { - return this.glFeatures.snippetMultipleFiles; - }, - filesLabel() { - return this.hasMultiFilesEnabled ? s__('Snippets|Files') : s__('Snippets|File'); - }, firstInputId() { const blobId = this.blobIds[0]; @@ -131,24 +123,23 @@ export default { }; </script> <template> - <div class="form-group file-editor"> - <label :for="firstInputId">{{ filesLabel }}</label> + <div class="form-group"> + <label :for="firstInputId">{{ s__('Snippets|Files') }}</label> <snippet-blob-edit v-for="(blobId, index) in blobIds" :key="blobId" :class="{ 'gl-mt-3': index > 0 }" :blob="blobs[blobId]" :can-delete="canDelete" - :show-delete="hasMultiFilesEnabled" @blob-updated="updateBlob(blobId, $event)" @delete="deleteBlob(blobId)" /> <gl-button - v-if="hasMultiFilesEnabled" :disabled="!canAdd" data-testid="add_button" class="gl-my-3" variant="dashed" + data-qa-selector="add_file_button" @click="addBlob" >{{ addLabel }}</gl-button > diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index f3f894ed649..6a10dc38f2c 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; -import BlobContentEdit from '~/blob/components/blob_edit_content.vue'; +import EditorLite from '~/vue_shared/components/editor_lite.vue'; import { getBaseURL, joinPaths } from '~/lib/utils/url_utility'; import axios from '~/lib/utils/axios_utils'; import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants'; @@ -11,8 +11,8 @@ import { sprintf } from '~/locale'; export default { components: { BlobHeaderEdit, - BlobContentEdit, GlLoadingIcon, + EditorLite, }, inheritAttrs: false, props: { @@ -28,7 +28,7 @@ export default { showDelete: { type: Boolean, required: false, - default: false, + default: true, }, }, computed: { @@ -69,7 +69,7 @@ export default { }; </script> <template> - <div class="file-holder snippet"> + <div class="file-holder snippet" data-qa-selector="file_holder_container"> <blob-header-edit :id="inputId" :value="blob.path" @@ -85,7 +85,7 @@ export default { size="lg" class="loading-animation prepend-top-20 gl-mb-6" /> - <blob-content-edit + <editor-lite v-else :value="blob.content" :file-global-id="blob.id" diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index b38be5bb9a4..e88126ea56a 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -23,6 +23,7 @@ export default { return { ids: this.snippet.id, rich: this.activeViewerType === RICH_BLOB_VIEWER, + paths: [this.blob.path], }; }, update(data) { @@ -79,8 +80,10 @@ export default { }, onContentUpdate(data) { const { path: blobPath } = this.blob; - const { blobs } = data.snippets.edges[0].node; - const updatedBlobData = blobs.find(blob => blob.path === blobPath); + const { + blobs: { nodes: dataBlobs }, + } = data.snippets.nodes[0]; + const updatedBlobData = dataBlobs.find(blob => blob.path === blobPath); return updatedBlobData.richData || updatedBlobData.plainData; }, }, diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue index 737845d09b8..5e6caf27bdd 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue @@ -49,6 +49,7 @@ export default { :add-spacing-classes="false" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" + :textarea-value="value" > <template #textarea> <textarea diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 0ca69f3161a..30de5a9d0e0 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -18,6 +18,7 @@ import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql'; import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql'; import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql'; import { joinPaths } from '~/lib/utils/url_utility'; +import { fetchPolicies } from '~/lib/graphql'; export default { components: { @@ -37,6 +38,7 @@ export default { }, apollo: { canCreateSnippet: { + fetchPolicy: fetchPolicies.NO_CACHE, query() { return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet; }, diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql index 2cca71708ca..d75b4011d1c 100644 --- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql +++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql @@ -12,18 +12,20 @@ fragment SnippetBase on Snippet { httpUrlToRepo sshUrlToRepo blobs { - binary - name - path - rawPath - size - externalStorage - renderedAsText - simpleViewer { - ...BlobViewer - } - richViewer { - ...BlobViewer + nodes { + binary + name + path + rawPath + size + externalStorage + renderedAsText + simpleViewer { + ...BlobViewer + } + richViewer { + ...BlobViewer + } } } userPermissions { diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js index c70ad9b95f8..b55e1baf41e 100644 --- a/app/assets/javascripts/snippets/index.js +++ b/app/assets/javascripts/snippets/index.js @@ -3,20 +3,18 @@ import VueApollo from 'vue-apollo'; import Translate from '~/vue_shared/translate'; import createDefaultClient from '~/lib/graphql'; -import SnippetsShow from './components/show.vue'; -import SnippetsEdit from './components/edit.vue'; import { SNIPPET_LEVELS_MAP, SNIPPET_VISIBILITY_PRIVATE } from '~/snippets/constants'; Vue.use(VueApollo); Vue.use(Translate); -function appFactory(el, Component) { +export default function appFactory(el, Component) { if (!el) { return false; } const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { batchMax: 1 }), }); const { @@ -46,11 +44,3 @@ function appFactory(el, Component) { }, }); } - -export const SnippetShowInit = () => { - appFactory(document.getElementById('js-snippet-view'), SnippetsShow); -}; - -export const SnippetEditInit = () => { - appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit); -}; diff --git a/app/assets/javascripts/snippets/mixins/snippets.js b/app/assets/javascripts/snippets/mixins/snippets.js index 15daaa8d84a..d5e69e2a889 100644 --- a/app/assets/javascripts/snippets/mixins/snippets.js +++ b/app/assets/javascripts/snippets/mixins/snippets.js @@ -11,9 +11,16 @@ export const getSnippetMixin = { ids: this.snippetGid, }; }, - update: data => data.snippets.edges[0]?.node, + update: data => { + const res = data.snippets.nodes[0]; + if (res) { + res.blobs = res.blobs.nodes; + } + + return res; + }, result(res) { - this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault; + this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault; if (this.onSnippetFetch) { this.onSnippetFetch(res); } diff --git a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql index 8f1f16b76c2..0e04ee9b7b8 100644 --- a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql +++ b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql @@ -1,9 +1,9 @@ -query SnippetBlobContent($ids: [ID!], $rich: Boolean!) { +query SnippetBlobContent($ids: [ID!], $rich: Boolean!, $paths: [String!]) { snippets(ids: $ids) { - edges { - node { - id - blobs { + nodes { + id + blobs(paths: $paths) { + nodes { path richData @include(if: $rich) plainData @skip(if: $rich) diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql index b23ab862439..2f385050d89 100644 --- a/app/assets/javascripts/snippets/queries/snippet.query.graphql +++ b/app/assets/javascripts/snippets/queries/snippet.query.graphql @@ -4,13 +4,11 @@ query GetSnippetQuery($ids: [ID!]) { snippets(ids: $ids) { - edges { - node { - ...SnippetBase - ...SnippetProject - author { - ...Author - } + nodes { + ...SnippetBase + ...SnippetProject + author { + ...Author } } } diff --git a/app/assets/javascripts/snippets/utils/blob.js b/app/assets/javascripts/snippets/utils/blob.js index 21f52671801..c47559b82b8 100644 --- a/app/assets/javascripts/snippets/utils/blob.js +++ b/app/assets/javascripts/snippets/utils/blob.js @@ -7,6 +7,8 @@ import { SNIPPET_LEVELS_MAP, SNIPPET_VISIBILITY, } from '../constants'; +import { performanceMarkAndMeasure } from '~/performance_utils'; +import { SNIPPET_MARK_BLOBS_CONTENT, SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance_constants'; const createLocalId = () => uniqueId('blob_local_'); @@ -79,3 +81,16 @@ export const defaultSnippetVisibilityLevels = arr => { } return []; }; + +export const markBlobPerformance = () => { + performanceMarkAndMeasure({ + mark: SNIPPET_MARK_BLOBS_CONTENT, + measures: [ + { + name: SNIPPET_MEASURE_BLOBS_CONTENT, + start: undefined, + end: SNIPPET_MARK_BLOBS_CONTENT, + }, + ], + }); +}; diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue new file mode 100644 index 00000000000..9f75c65a316 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue @@ -0,0 +1,104 @@ +<script> +import { GlForm, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui'; +import AccessorUtilities from '~/lib/utils/accessor'; + +export default { + components: { + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, + }, + props: { + title: { + type: String, + required: true, + }, + description: { + type: String, + required: true, + }, + }, + data() { + return { + editable: { + title: this.title, + description: this.description, + }, + }; + }, + computed: { + editableStorageKey() { + return this.getId('local-storage', 'editable'); + }, + hasLocalStorage() { + return AccessorUtilities.isLocalStorageAccessSafe(); + }, + }, + mounted() { + this.initCachedEditable(); + this.preSelect(); + }, + methods: { + getId(type, key) { + return `sse-merge-request-meta-${type}-${key}`; + }, + initCachedEditable() { + if (this.hasLocalStorage) { + const cachedEditable = JSON.parse(localStorage.getItem(this.editableStorageKey)); + if (cachedEditable) { + this.editable = cachedEditable; + } + } + }, + preSelect() { + this.$nextTick(() => { + this.$refs.title.$el.select(); + }); + }, + resetCachedEditable() { + if (this.hasLocalStorage) { + window.localStorage.removeItem(this.editableStorageKey); + } + }, + onUpdate() { + const payload = { ...this.editable }; + this.$emit('updateSettings', payload); + + if (this.hasLocalStorage) { + window.localStorage.setItem(this.editableStorageKey, JSON.stringify(payload)); + } + }, + }, +}; +</script> + +<template> + <gl-form> + <gl-form-group + key="title" + :label="__('Brief title about the change')" + :label-for="getId('control', 'title')" + > + <gl-form-input + :id="getId('control', 'title')" + ref="title" + v-model.lazy="editable.title" + type="text" + @input="onUpdate" + /> + </gl-form-group> + + <gl-form-group + key="description" + :label="__('Goal of the changes and what reviewers should be aware of')" + :label-for="getId('control', 'description')" + > + <gl-form-textarea + :id="getId('control', 'description')" + v-model.lazy="editable.description" + @input="onUpdate" + /> + </gl-form-group> + </gl-form> +</template> diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue new file mode 100644 index 00000000000..4e5245bd892 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue @@ -0,0 +1,85 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; + +import EditMetaControls from './edit_meta_controls.vue'; + +export default { + components: { + GlModal, + EditMetaControls, + }, + props: { + sourcePath: { + type: String, + required: true, + }, + }, + data() { + return { + mergeRequestMeta: { + title: sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), { + sourcePath: this.sourcePath, + }), + description: s__('StaticSiteEditor|Copy update'), + }, + }; + }, + computed: { + disabled() { + return this.mergeRequestMeta.title === ''; + }, + primaryProps() { + return { + text: __('Submit changes'), + attributes: [{ variant: 'success' }, { disabled: this.disabled }], + }; + }, + secondaryProps() { + return { + text: __('Keep editing'), + attributes: [{ variant: 'default' }], + }; + }, + }, + methods: { + hide() { + this.$refs.modal.hide(); + }, + show() { + this.$refs.modal.show(); + }, + onPrimary() { + this.$emit('primary', this.mergeRequestMeta); + this.$refs.editMetaControls.resetCachedEditable(); + }, + onSecondary() { + this.hide(); + }, + onUpdateSettings(mergeRequestMeta) { + this.mergeRequestMeta = { ...mergeRequestMeta }; + }, + }, +}; +</script> + +<template> + <gl-modal + ref="modal" + modal-id="edit-meta-modal" + :title="__('Submit your changes')" + :action-primary="primaryProps" + :action-secondary="secondaryProps" + size="sm" + @primary="onPrimary" + @secondary="onSecondary" + @hide="() => $emit('hide')" + > + <edit-meta-controls + ref="editMetaControls" + :title="mergeRequestMeta.title" + :description="mergeRequestMeta.description" + @updateSettings="onUpdateSettings" + /> + </gl-modal> +</template> diff --git a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue index 2d62964cb3b..3bb5a0b8fd5 100644 --- a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue +++ b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue @@ -41,7 +41,7 @@ export default { :disabled="savingChanges" @click="$emit('editSettings')" > - {{ __('Settings') }} + {{ __('Page settings') }} </gl-button> <gl-button ref="submit" @@ -50,7 +50,7 @@ export default { :loading="savingChanges" @click="$emit('submit')" > - {{ __('Submit changes') }} + {{ __('Submit changes...') }} </gl-button> </div> </div> diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js index 0a5d8c07ad9..cc68bc57bb0 100644 --- a/app/assets/javascripts/static_site_editor/graphql/index.js +++ b/app/assets/javascripts/static_site_editor/graphql/index.js @@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql'; import typeDefs from './typedefs.graphql'; import fileResolver from './resolvers/file'; import submitContentChangesResolver from './resolvers/submit_content_changes'; +import hasSubmittedChangesResolver from './resolvers/has_submitted_changes'; Vue.use(VueApollo); @@ -15,10 +16,12 @@ const createApolloProvider = appData => { }, Mutation: { submitContentChanges: submitContentChangesResolver, + hasSubmittedChanges: hasSubmittedChangesResolver, }, }, { typeDefs, + assumeImmutableResults: true, }, ); diff --git a/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql b/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql new file mode 100644 index 00000000000..1f47929556a --- /dev/null +++ b/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql @@ -0,0 +1,5 @@ +mutation hasSubmittedChanges($input: HasSubmittedChangesInput) { + hasSubmittedChanges(input: $input) @client { + hasSubmittedChanges + } +} diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql index 946d80efff0..9f4b0afe55f 100644 --- a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql @@ -1,6 +1,7 @@ query appData { appData @client { isSupportedContent + hasSubmittedChanges project sourcePath username diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js new file mode 100644 index 00000000000..ea49b21eb0d --- /dev/null +++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js @@ -0,0 +1,25 @@ +import { produce } from 'immer'; +import query from '../queries/app_data.query.graphql'; + +const hasSubmittedChangesResolver = (_, { input: { hasSubmittedChanges } }, { cache }) => { + const oldData = cache.readQuery({ query }); + + const data = produce(oldData, draftState => { + // punctually modifying draftState as per immer docs upsets our linters + return { + ...draftState, + appData: { + __typename: 'AppData', + ...draftState.appData, + hasSubmittedChanges, + }, + }; + }); + + cache.writeQuery({ + query, + data, + }); +}; + +export default hasSubmittedChangesResolver; diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js index 0cb26f88785..4137ede49c6 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 @@ -1,24 +1,34 @@ +import { produce } from 'immer'; import submitContentChanges from '../../services/submit_content_changes'; import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql'; const submitContentChangesResolver = ( _, - { input: { project: projectId, username, sourcePath, content, images } }, + { input: { project: projectId, username, sourcePath, content, images, mergeRequestMeta } }, { cache }, ) => { - return submitContentChanges({ projectId, username, sourcePath, content, images }).then( - savedContentMeta => { - cache.writeQuery({ - query: savedContentMetaQuery, - data: { - savedContentMeta: { - __typename: 'SavedContentMeta', - ...savedContentMeta, - }, + return submitContentChanges({ + projectId, + username, + sourcePath, + content, + images, + mergeRequestMeta, + }).then(savedContentMeta => { + const data = produce(savedContentMeta, draftState => { + return { + savedContentMeta: { + __typename: 'SavedContentMeta', + ...draftState, }, - }); - }, - ); + }; + }); + + cache.writeQuery({ + query: savedContentMetaQuery, + data, + }); + }); }; export default submitContentChangesResolver; diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql index 78cc1746cdb..0ded1722d26 100644 --- a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql @@ -16,12 +16,17 @@ type SavedContentMeta { type AppData { isSupportedContent: Boolean! + hasSubmittedChanges: Boolean! project: String! returnUrl: String sourcePath: String! username: String! } +input HasSubmittedChangesInput { + hasSubmittedChanges: Boolean! +} + input SubmitContentChangesInput { project: String! sourcePath: String! @@ -40,4 +45,5 @@ extend type Query { extend type Mutation { submitContentChanges(input: SubmitContentChangesInput!): SavedContentMeta + hasSubmittedChanges(input: HasSubmittedChangesInput!): AppData } diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js index b7e5ea4eee3..fceef8f9084 100644 --- a/app/assets/javascripts/static_site_editor/index.js +++ b/app/assets/javascripts/static_site_editor/index.js @@ -12,13 +12,23 @@ const initStaticSiteEditor = el => { namespace, project, mergeRequestsIllustrationPath, + // NOTE: The following variables are not yet used, but are supported by the config file, + // so we are adding them here as a convenience for future use. + // eslint-disable-next-line no-unused-vars + staticSiteGenerator, + // eslint-disable-next-line no-unused-vars + imageUploadPath, + mounts, } = el.dataset; + // NOTE that the object in 'mounts' is a JSON string from the data attribute, so it must be parsed into an object. + // eslint-disable-next-line no-unused-vars + const mountsObject = JSON.parse(mounts); const { current_username: username } = window.gon; const returnUrl = el.dataset.returnUrl || null; - const router = createRouter(baseUrl); const apolloProvider = createApolloProvider({ isSupportedContent: parseBoolean(isSupportedContent), + hasSubmittedChanges: false, project: `${namespace}/${project}`, returnUrl, sourcePath, diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index eef2bd88f0e..27bd1c99ae2 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -1,13 +1,16 @@ <script> +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import Tracking from '~/tracking'; + import SkeletonLoader from '../components/skeleton_loader.vue'; import EditArea from '../components/edit_area.vue'; +import EditMetaModal from '../components/edit_meta_modal.vue'; import InvalidContentMessage from '../components/invalid_content_message.vue'; import SubmitChangesError from '../components/submit_changes_error.vue'; import appDataQuery from '../graphql/queries/app_data.query.graphql'; import sourceContentQuery from '../graphql/queries/source_content.query.graphql'; +import hasSubmittedChangesMutation from '../graphql/mutations/has_submitted_changes.mutation.graphql'; import submitContentChangesMutation from '../graphql/mutations/submit_content_changes.mutation.graphql'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; -import Tracking from '~/tracking'; import { LOAD_CONTENT_ERROR, TRACKING_ACTION_INITIALIZE_EDITOR } from '../constants'; import { SUCCESS_ROUTE } from '../router/constants'; @@ -15,6 +18,7 @@ export default { components: { SkeletonLoader, EditArea, + EditMetaModal, InvalidContentMessage, SubmitChangesError, }, @@ -48,6 +52,7 @@ export default { data() { return { content: null, + images: null, submitChangesError: null, isSavingChanges: false, }; @@ -64,15 +69,34 @@ export default { Tracking.event(document.body.dataset.page, TRACKING_ACTION_INITIALIZE_EDITOR); }, methods: { + onHideModal() { + this.isSavingChanges = false; + this.$refs.editMetaModal.hide(); + }, onDismissError() { this.submitChangesError = null; }, - onSubmit({ content, images }) { + onPrepareSubmit({ content, images }) { this.content = content; - this.submitChanges(images); - }, - submitChanges(images) { + this.images = images; + this.isSavingChanges = true; + this.$refs.editMetaModal.show(); + }, + onSubmit(mergeRequestMeta) { + // eslint-disable-next-line promise/catch-or-return + this.$apollo + .mutate({ + mutation: hasSubmittedChangesMutation, + variables: { + input: { + hasSubmittedChanges: true, + }, + }, + }) + .finally(() => { + this.$router.push(SUCCESS_ROUTE); + }); this.$apollo .mutate({ @@ -83,13 +107,11 @@ export default { username: this.appData.username, sourcePath: this.appData.sourcePath, content: this.content, - images, + images: this.images, + mergeRequestMeta, }, }, }) - .then(() => { - this.$router.push(SUCCESS_ROUTE); - }) .catch(e => { this.submitChangesError = e.message; }) @@ -107,7 +129,7 @@ export default { <submit-changes-error v-if="submitChangesError" :error="submitChangesError" - @retry="submitChanges" + @retry="onSubmit" @dismiss="onDismissError" /> <edit-area @@ -116,7 +138,13 @@ export default { :content="sourceContent.content" :saving-changes="isSavingChanges" :return-url="appData.returnUrl" - @submit="onSubmit" + @submit="onPrepareSubmit" + /> + <edit-meta-modal + ref="editMetaModal" + :source-path="appData.sourcePath" + @primary="onSubmit" + @hide="onHideModal" /> </template> diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue index f0d597d7c9b..1ee1a3b0edf 100644 --- a/app/assets/javascripts/static_site_editor/pages/success.vue +++ b/app/assets/javascripts/static_site_editor/pages/success.vue @@ -1,5 +1,5 @@ <script> -import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { s__, __, sprintf } from '~/locale'; import savedContentMetaQuery from '../graphql/queries/saved_content_meta.query.graphql'; @@ -8,8 +8,9 @@ import { HOME_ROUTE } from '../router/constants'; export default { components: { - GlEmptyState, GlButton, + GlEmptyState, + GlLoadingIcon, }, props: { mergeRequestsIllustrationPath: { @@ -33,7 +34,7 @@ export default { }, }, created() { - if (!this.savedContentMeta) { + if (!this.appData.hasSubmittedChanges) { this.$router.push(HOME_ROUTE); } }, @@ -50,40 +51,56 @@ export default { assignMergeRequestInstruction: s__( 'StaticSiteEditor|3. Assign a person to review and accept the merge request.', ), + submittingTitle: s__('StaticSiteEditor|Creating your merge request'), + submittingNotePrimary: s__( + 'StaticSiteEditor|You can set an assignee to get your changes reviewed and deployed once your merge request is created.', + ), + submittingNoteSecondary: s__( + 'StaticSiteEditor|A link to view the merge request will appear once ready.', + ), }; </script> <template> - <div - v-if="savedContentMeta" - class="container gl-flex-grow-1 gl-display-flex gl-flex-direction-column" - > - <div class="gl-fixed gl-left-0 gl-right-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"> + <div> + <div class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"> <div class="container gl-py-4"> - <gl-button - v-if="appData.returnUrl" - ref="returnToSiteButton" - class="gl-mr-5" - :href="appData.returnUrl" - >{{ $options.returnToSiteBtnText }}</gl-button - > - <strong> - {{ updatedFileDescription }} - </strong> + <div class="gl-display-flex"> + <gl-button + v-if="appData.returnUrl" + ref="returnToSiteButton" + class="gl-mr-5 gl-align-self-start" + :href="appData.returnUrl" + >{{ $options.returnToSiteBtnText }}</gl-button + > + <strong class="gl-mt-2"> + {{ updatedFileDescription }} + </strong> + </div> </div> </div> - <gl-empty-state - class="gl-my-9" - :primary-button-text="$options.primaryButtonText" - :title="$options.title" - :primary-button-link="savedContentMeta.mergeRequest.url" - :svg-path="mergeRequestsIllustrationPath" - > - <template #description> - <p>{{ $options.mergeRequestInstructionsHeading }}</p> - <p>{{ $options.addTitleInstruction }}</p> - <p>{{ $options.addDescriptionInstruction }}</p> - <p>{{ $options.assignMergeRequestInstruction }}</p> - </template> - </gl-empty-state> + <div class="container"> + <gl-empty-state + class="gl-my-7" + :title="savedContentMeta ? $options.title : $options.submittingTitle" + :primary-button-text="savedContentMeta && $options.primaryButtonText" + :primary-button-link="savedContentMeta && savedContentMeta.mergeRequest.url" + :svg-path="mergeRequestsIllustrationPath" + :svg-height="146" + > + <template #description> + <div v-if="savedContentMeta"> + <p>{{ $options.mergeRequestInstructionsHeading }}</p> + <p>{{ $options.addTitleInstruction }}</p> + <p>{{ $options.addDescriptionInstruction }}</p> + <p>{{ $options.assignMergeRequestInstruction }}</p> + </div> + <div v-else> + <p>{{ $options.submittingNotePrimary }}</p> + <p>{{ $options.submittingNoteSecondary }}</p> + <gl-loading-icon size="xl" /> + </div> + </template> + </gl-empty-state> + </div> </div> </template> diff --git a/app/assets/javascripts/static_site_editor/services/front_matterify.js b/app/assets/javascripts/static_site_editor/services/front_matterify.js new file mode 100644 index 00000000000..cbf0fffd515 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/services/front_matterify.js @@ -0,0 +1,73 @@ +import jsYaml from 'js-yaml'; + +const NEW_LINE = '\n'; + +const hasMatter = (firstThreeChars, fourthChar) => { + const isYamlDelimiter = firstThreeChars === '---'; + const isFourthCharNewline = fourthChar === NEW_LINE; + return isYamlDelimiter && isFourthCharNewline; +}; + +export const frontMatterify = source => { + let index = 3; + let offset; + const delimiter = source.slice(0, index); + const type = 'yaml'; + const NO_FRONTMATTER = { + source, + matter: null, + spacing: null, + content: source, + delimiter: null, + type: null, + }; + + if (!hasMatter(delimiter, source.charAt(index))) { + return NO_FRONTMATTER; + } + + offset = source.indexOf(delimiter, index); + + // Finds the end delimiter that starts at a new line + while (offset !== -1 && source.charAt(offset - 1) !== NEW_LINE) { + index = offset + delimiter.length; + offset = source.indexOf(delimiter, index); + } + + if (offset === -1) { + return NO_FRONTMATTER; + } + + const matterStr = source.slice(index, offset); + const matter = jsYaml.safeLoad(matterStr); + + let content = source.slice(offset + delimiter.length); + let spacing = ''; + let idx = 0; + while (content.charAt(idx).match(/(\s|\n)/)) { + spacing += content.charAt(idx); + idx += 1; + } + content = content.replace(spacing, ''); + + return { + source, + matter, + spacing, + content, + delimiter, + type, + }; +}; + +export const stringify = ({ matter, spacing, content, delimiter }, newMatter) => { + const matterObj = newMatter || matter; + + if (!matterObj) { + return content; + } + + const header = `${delimiter}${NEW_LINE}${jsYaml.safeDump(matterObj)}${delimiter}`; + const body = `${spacing}${content}`; + return `${header}${body}`; +}; diff --git a/app/assets/javascripts/static_site_editor/services/parse_source_file.js b/app/assets/javascripts/static_site_editor/services/parse_source_file.js index 640186ee1d0..d4fc8b2edb6 100644 --- a/app/assets/javascripts/static_site_editor/services/parse_source_file.js +++ b/app/assets/javascripts/static_site_editor/services/parse_source_file.js @@ -1,7 +1,7 @@ -import grayMatter from 'gray-matter'; +import { frontMatterify, stringify } from './front_matterify'; const parseSourceFile = raw => { - const remake = source => grayMatter(source, {}); + const remake = source => frontMatterify(source); let editable = remake(raw); @@ -13,20 +13,17 @@ const parseSourceFile = raw => { } }; - const trimmedEditable = () => grayMatter.stringify(editable).trim(); + const content = (isBody = false) => (isBody ? editable.content : stringify(editable)); - const content = (isBody = false) => (isBody ? editable.content.trim() : trimmedEditable()); // gray-matter internally adds an eof newline so we trim to bypass, open issue: https://github.com/jonschlinkert/gray-matter/issues/96 - - const matter = () => editable.data; + const matter = () => editable.matter; const syncMatter = settings => { - const source = grayMatter.stringify(editable.content, settings); - syncContent(source); + editable.matter = settings; }; - const isModified = () => trimmedEditable() !== raw; + const isModified = () => stringify(editable) !== raw; - const hasMatter = () => editable.matter.length > 0; + const hasMatter = () => Boolean(editable.matter); return { matter, diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js index da62d3fa4fc..8623a671a7d 100644 --- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js +++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js @@ -1,6 +1,5 @@ import Api from '~/api'; import Tracking from '~/tracking'; -import { s__, sprintf } from '~/locale'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; import generateBranchName from '~/static_site_editor/services/generate_branch_name'; @@ -71,6 +70,7 @@ const commitContent = (projectId, message, branch, sourcePath, content, images) const createMergeRequest = ( projectId, title, + description, sourceBranch, targetBranch = DEFAULT_TARGET_BRANCH, ) => { @@ -80,6 +80,7 @@ const createMergeRequest = ( projectId, convertObjectPropsToSnakeCase({ title, + description, sourceBranch, targetBranch, }), @@ -88,11 +89,16 @@ const createMergeRequest = ( }); }; -const submitContentChanges = ({ username, projectId, sourcePath, content, images }) => { +const submitContentChanges = ({ + username, + projectId, + sourcePath, + content, + images, + mergeRequestMeta, +}) => { const branch = generateBranchName(username); - const mergeRequestTitle = sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), { - sourcePath, - }); + const { title: mergeRequestTitle, description: mergeRequestDescription } = mergeRequestMeta; const meta = {}; return createBranch(projectId, branch) @@ -104,7 +110,7 @@ const submitContentChanges = ({ username, projectId, sourcePath, content, images .then(({ data: { short_id: label, web_url: url } }) => { Object.assign(meta, { commit: { label, url } }); - return createMergeRequest(projectId, mergeRequestTitle, branch); + return createMergeRequest(projectId, mergeRequestTitle, mergeRequestDescription, branch); }) .then(({ data: { iid: label, web_url: url } }) => { Object.assign(meta, { mergeRequest: { label: label.toString(), url } }); diff --git a/app/assets/javascripts/static_site_editor/services/templater.js b/app/assets/javascripts/static_site_editor/services/templater.js index a1c1bb6b8d6..d302aea78a3 100644 --- a/app/assets/javascripts/static_site_editor/services/templater.js +++ b/app/assets/javascripts/static_site_editor/services/templater.js @@ -15,7 +15,7 @@ const markPrefix = `${marker}-${Date.now()}`; const reHelpers = { template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`, - openTag: '<[a-zA-Z]+.*?>', + openTag: '<(?!figure|iframe)[a-zA-Z]+.*?>', closeTag: '</.+>', }; const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm'); diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index f2b05946a08..b51951674d5 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -31,6 +31,15 @@ export default class TaskList { init() { this.disable(); // Prevent duplicate event bindings + const taskListFields = document.querySelectorAll( + `${this.taskListContainerSelector} .js-task-list-field[data-value]`, + ); + + taskListFields.forEach(taskListField => { + // eslint-disable-next-line no-param-reassign + taskListField.value = taskListField.dataset.value; + }); + $(this.taskListContainerSelector).taskList('enable'); $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler); } diff --git a/app/assets/javascripts/test_utils/simulate_drag.js b/app/assets/javascripts/test_utils/simulate_drag.js index f4090de3f1e..321315d531b 100644 --- a/app/assets/javascripts/test_utils/simulate_drag.js +++ b/app/assets/javascripts/test_utils/simulate_drag.js @@ -143,7 +143,7 @@ export default function simulateDrag(options) { const dragInterval = setInterval(() => { const progress = (new Date().getTime() - startTime) / duration; const x = fromRect.cx + (toRect.cx - fromRect.cx) * progress; - const y = fromRect.cy + (toRect.cy - fromRect.cy) * progress; + const y = fromRect.cy + (toRect.cy - fromRect.cy + options.extraHeight) * progress; const overEl = fromEl.ownerDocument.elementFromPoint(x, y); simulateEvent(overEl, 'pointermove', { diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js index cfbd88d6c40..9f5dce4183c 100644 --- a/app/assets/javascripts/tooltips/index.js +++ b/app/assets/javascripts/tooltips/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import jQuery from 'jquery'; -import { toArray, isFunction } from 'lodash'; +import { toArray, isFunction, isElement } from 'lodash'; import Tooltips from './components/tooltips.vue'; let app; @@ -54,10 +54,16 @@ const handleTooltipEvent = (rootTarget, e, selector, config = {}) => { } }; -const applyToElements = (elements, handler) => toArray(elements).forEach(handler); +const applyToElements = (elements, handler) => { + const iterable = isElement(elements) ? [elements] : toArray(elements); + + toArray(iterable).forEach(handler); +}; const invokeBootstrapApi = (elements, method) => { if (isFunction(elements.tooltip)) { + elements.tooltip(method); + } else { jQuery(elements).tooltip(method); } }; diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js index 37ebe6b6c4d..c1521882682 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking.js @@ -9,7 +9,7 @@ const DEFAULT_SNOWPLOW_OPTIONS = { respectDoNotTrack: true, forceSecureTracker: true, eventMethod: 'post', - contexts: { webPage: true }, + contexts: { webPage: true, performanceTiming: true }, formTracking: false, linkClickTracking: false, }; diff --git a/app/assets/javascripts/user_lists/components/add_user_modal.vue b/app/assets/javascripts/user_lists/components/add_user_modal.vue new file mode 100644 index 00000000000..a8dde1f681e --- /dev/null +++ b/app/assets/javascripts/user_lists/components/add_user_modal.vue @@ -0,0 +1,72 @@ +<script> +import { GlModal, GlFormGroup, GlFormTextarea } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { ADD_USER_MODAL_ID } from '../constants/show'; + +export default { + components: { + GlFormGroup, + GlFormTextarea, + GlModal, + }, + props: { + visible: { + type: Boolean, + required: false, + default: false, + }, + }, + modalOptions: { + actionPrimary: { + text: s__('UserLists|Add'), + attributes: [{ 'data-testid': 'confirm-add-user-ids' }], + }, + actionCancel: { + text: s__('UserLists|Cancel'), + attributes: [{ 'data-testid': 'cancel-add-user-ids' }], + }, + modalId: ADD_USER_MODAL_ID, + static: true, + }, + translations: { + title: s__('UserLists|Add users'), + description: s__( + 'UserLists|Enter a comma separated list of user IDs. These IDs should be the users of the system in which the feature flag is set, not GitLab IDs', + ), + userIdsLabel: s__('UserLists|User IDs'), + }, + data() { + return { + userIds: '', + }; + }, + methods: { + submitUsers() { + this.$emit('addUsers', this.userIds); + this.clearInput(); + }, + clearInput() { + this.userIds = ''; + }, + }, +}; +</script> +<template> + <gl-modal + v-bind="$options.modalOptions" + :visible="visible" + data-testid="add-users-modal" + @primary="submitUsers" + @canceled="clearInput" + > + <template #modal-title> + {{ $options.translations.title }} + </template> + <template #default> + <p data-testid="add-userids-description">{{ $options.translations.description }}</p> + <gl-form-group label-for="add-user-ids" :label="$options.translations.userIdsLabel"> + <gl-form-textarea id="add-user-ids" v-model="userIds" /> + </gl-form-group> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/user_lists/components/edit_user_list.vue b/app/assets/javascripts/user_lists/components/edit_user_list.vue new file mode 100644 index 00000000000..d56c3d61027 --- /dev/null +++ b/app/assets/javascripts/user_lists/components/edit_user_list.vue @@ -0,0 +1,74 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import statuses from '../constants/edit'; +import UserListForm from './user_list_form.vue'; + +export default { + components: { + GlAlert, + GlLoadingIcon, + UserListForm, + }, + inject: ['userListsDocsPath'], + translations: { + saveButtonLabel: s__('UserLists|Save'), + }, + computed: { + ...mapState(['userList', 'status', 'errorMessage']), + title() { + return sprintf(s__('UserLists|Edit %{name}'), { name: this.userList?.name }); + }, + isLoading() { + return this.status === statuses.LOADING; + }, + isError() { + return this.status === statuses.ERROR; + }, + hasUserList() { + return Boolean(this.userList); + }, + }, + mounted() { + this.fetchUserList(); + }, + methods: { + ...mapActions(['fetchUserList', 'updateUserList', 'dismissErrorAlert']), + }, +}; +</script> +<template> + <div> + <gl-alert + v-if="isError" + :dismissible="hasUserList" + variant="danger" + @dismiss="dismissErrorAlert" + > + <ul class="gl-mb-0"> + <li v-for="(message, index) in errorMessage" :key="index"> + {{ message }} + </li> + </ul> + </gl-alert> + + <gl-loading-icon v-if="isLoading" size="xl" /> + + <template v-else-if="hasUserList"> + <h3 + data-testid="user-list-title" + class="gl-font-weight-bold gl-pb-5 gl-border-b-solid gl-border-gray-100 gl-border-1" + > + {{ title }} + </h3> + <user-list-form + :cancel-path="userList.path" + :save-button-label="$options.translations.saveButtonLabel" + :user-lists-docs-path="userListsDocsPath" + :user-list="userList" + @submit="updateUserList" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/user_lists/components/new_user_list.vue b/app/assets/javascripts/user_lists/components/new_user_list.vue new file mode 100644 index 00000000000..522e077fb25 --- /dev/null +++ b/app/assets/javascripts/user_lists/components/new_user_list.vue @@ -0,0 +1,50 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlAlert } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import UserListForm from './user_list_form.vue'; + +export default { + components: { + GlAlert, + UserListForm, + }, + inject: ['userListsDocsPath', 'featureFlagsPath'], + translations: { + pageTitle: s__('UserLists|New list'), + createButtonLabel: s__('UserLists|Create'), + }, + computed: { + ...mapState(['userList', 'errorMessage']), + isError() { + return Array.isArray(this.errorMessage) && this.errorMessage.length > 0; + }, + }, + methods: { + ...mapActions(['createUserList', 'dismissErrorAlert']), + }, +}; +</script> +<template> + <div> + <gl-alert v-if="isError" variant="danger" @dismiss="dismissErrorAlert"> + <ul class="gl-mb-0"> + <li v-for="(message, index) in errorMessage" :key="index"> + {{ message }} + </li> + </ul> + </gl-alert> + + <h3 class="gl-font-weight-bold gl-pb-5 gl-border-b-solid gl-border-gray-100 gl-border-1"> + {{ $options.translations.pageTitle }} + </h3> + + <user-list-form + :cancel-path="featureFlagsPath" + :save-button-label="$options.translations.createButtonLabel" + :user-lists-docs-path="userListsDocsPath" + :user-list="userList" + @submit="createUserList" + /> + </div> +</template> diff --git a/app/assets/javascripts/user_lists/components/user_list.vue b/app/assets/javascripts/user_lists/components/user_list.vue new file mode 100644 index 00000000000..0e2b72c1423 --- /dev/null +++ b/app/assets/javascripts/user_lists/components/user_list.vue @@ -0,0 +1,142 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { + GlAlert, + GlButton, + GlEmptyState, + GlLoadingIcon, + GlModalDirective as GlModal, +} from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import { states, ADD_USER_MODAL_ID } from '../constants/show'; +import AddUserModal from './add_user_modal.vue'; + +const commonTableClasses = ['gl-py-5', 'gl-border-b-1', 'gl-border-b-solid', 'gl-border-gray-100']; + +export default { + components: { + GlAlert, + GlButton, + GlEmptyState, + GlLoadingIcon, + AddUserModal, + }, + directives: { + GlModal, + }, + props: { + emptyStatePath: { + required: true, + type: String, + }, + }, + translations: { + addUserButtonLabel: s__('UserLists|Add Users'), + emptyStateTitle: s__('UserLists|There are no users'), + emptyStateDescription: s__( + 'UserLists|Define a set of users to be used within feature flag strategies', + ), + userIdLabel: s__('UserLists|User IDs'), + userIdColumnHeader: s__('UserLists|User ID'), + errorMessage: __('Something went wrong on our end. Please try again!'), + editButtonLabel: s__('UserLists|Edit'), + }, + classes: { + headerClasses: [ + 'gl-display-flex', + 'gl-justify-content-space-between', + 'gl-pb-5', + 'gl-border-b-1', + 'gl-border-b-solid', + 'gl-border-gray-100', + ].join(' '), + tableHeaderClasses: commonTableClasses.join(' '), + tableRowClasses: [ + ...commonTableClasses, + 'gl-display-flex', + 'gl-justify-content-space-between', + 'gl-align-items-center', + ].join(' '), + }, + ADD_USER_MODAL_ID, + computed: { + ...mapState(['userList', 'userIds', 'state']), + name() { + return this.userList?.name ?? ''; + }, + hasUserIds() { + return this.userIds.length > 0; + }, + isLoading() { + return this.state === states.LOADING; + }, + hasError() { + return this.state === states.ERROR; + }, + editPath() { + return this.userList?.edit_path; + }, + }, + mounted() { + this.fetchUserList(); + }, + methods: { + ...mapActions(['fetchUserList', 'dismissErrorAlert', 'removeUserId', 'addUserIds']), + }, +}; +</script> +<template> + <div> + <gl-alert v-if="hasError" variant="danger" @dismiss="dismissErrorAlert"> + {{ $options.translations.errorMessage }} + </gl-alert> + <gl-loading-icon v-if="isLoading" size="xl" class="gl-mt-6" /> + <div v-else> + <add-user-modal @addUsers="addUserIds" /> + <div :class="$options.classes.headerClasses"> + <div> + <h3>{{ name }}</h3> + <h4 class="gl-text-gray-500">{{ $options.translations.userIdLabel }}</h4> + </div> + <div class="gl-mt-6"> + <gl-button v-if="editPath" :href="editPath" data-testid="edit-user-list" class="gl-mr-3"> + {{ $options.translations.editButtonLabel }} + </gl-button> + <gl-button + v-gl-modal="$options.ADD_USER_MODAL_ID" + data-testid="add-users" + variant="success" + > + {{ $options.translations.addUserButtonLabel }} + </gl-button> + </div> + </div> + <div v-if="hasUserIds"> + <div :class="$options.classes.tableHeaderClasses"> + {{ $options.translations.userIdColumnHeader }} + </div> + <div + v-for="id in userIds" + :key="id" + data-testid="user-id-row" + :class="$options.classes.tableRowClasses" + > + <span data-testid="user-id">{{ id }}</span> + <gl-button + category="secondary" + variant="danger" + icon="remove" + data-testid="delete-user-id" + @click="removeUserId(id)" + /> + </div> + </div> + <gl-empty-state + v-else + :title="$options.translations.emptyStateTitle" + :description="$options.translations.emptyStateDescription" + :svg-path="emptyStatePath" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/user_lists/components/user_list_form.vue b/app/assets/javascripts/user_lists/components/user_list_form.vue new file mode 100644 index 00000000000..657acb51fee --- /dev/null +++ b/app/assets/javascripts/user_lists/components/user_list_form.vue @@ -0,0 +1,97 @@ +<script> +import { GlButton, GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlButton, + GlFormGroup, + GlFormInput, + GlLink, + GlSprintf, + }, + props: { + cancelPath: { + type: String, + required: true, + }, + saveButtonLabel: { + type: String, + required: true, + }, + userListsDocsPath: { + type: String, + required: true, + }, + userList: { + type: Object, + required: true, + }, + }, + classes: { + actionContainer: [ + 'gl-py-5', + 'gl-display-flex', + 'gl-justify-content-space-between', + 'gl-px-4', + 'gl-border-t-solid', + 'gl-border-gray-100', + 'gl-border-1', + 'gl-bg-gray-10', + ], + }, + translations: { + formLabel: s__('UserLists|Feature flag list'), + formSubtitle: s__( + 'UserLists|Lists allow you to define a set of users to be used with feature flags. %{linkStart}Read more about feature flag lists.%{linkEnd}', + ), + nameLabel: s__('UserLists|Name'), + cancelButtonLabel: s__('UserLists|Cancel'), + }, + data() { + return { + name: this.userList.name, + }; + }, + methods: { + submit() { + this.$emit('submit', { ...this.userList, name: this.name }); + }, + }, +}; +</script> +<template> + <div> + <div class="gl-display-flex gl-mt-7"> + <div class="gl-flex-basis-0 gl-mr-7"> + <h4 class="gl-min-width-fit-content gl-white-space-nowrap"> + {{ $options.translations.formLabel }} + </h4> + <gl-sprintf :message="$options.translations.formSubtitle" class="gl-text-gray-500"> + <template #link="{ content }"> + <gl-link :href="userListsDocsPath" data-testid="user-list-docs-link"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> + <div class="gl-flex-fill-1 gl-ml-7"> + <gl-form-group + label-for="user-list-name" + :label="$options.translations.nameLabel" + class="gl-mb-7" + > + <gl-form-input id="user-list-name" v-model="name" data-testid="user-list-name" required /> + </gl-form-group> + <div :class="$options.classes.actionContainer"> + <gl-button variant="success" data-testid="save-user-list" @click="submit"> + {{ saveButtonLabel }} + </gl-button> + <gl-button :href="cancelPath" data-testid="user-list-cancel"> + {{ $options.translations.cancelButtonLabel }} + </gl-button> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/user_lists/constants/edit.js b/app/assets/javascripts/user_lists/constants/edit.js new file mode 100644 index 00000000000..33378f0d39f --- /dev/null +++ b/app/assets/javascripts/user_lists/constants/edit.js @@ -0,0 +1,6 @@ +export default Object.freeze({ + LOADING: 'LOADING', + SUCCESS: 'SUCCESS', + ERROR: 'ERROR', + UNSYNCED: 'UNSYNCED', +}); diff --git a/app/assets/javascripts/user_lists/constants/show.js b/app/assets/javascripts/user_lists/constants/show.js new file mode 100644 index 00000000000..045375d5900 --- /dev/null +++ b/app/assets/javascripts/user_lists/constants/show.js @@ -0,0 +1,8 @@ +export const states = Object.freeze({ + LOADING: 'LOADING', + SUCCESS: 'SUCCESS', + ERROR: 'ERROR', + ERROR_DISMISSED: 'ERROR_DISMISSED', +}); + +export const ADD_USER_MODAL_ID = 'add-userids-modal'; diff --git a/app/assets/javascripts/user_lists/store/edit/actions.js b/app/assets/javascripts/user_lists/store/edit/actions.js new file mode 100644 index 00000000000..8f0a2bafec7 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/edit/actions.js @@ -0,0 +1,22 @@ +import Api from '~/api'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { getErrorMessages } from '../utils'; +import * as types from './mutation_types'; + +export const fetchUserList = ({ commit, state }) => { + commit(types.REQUEST_USER_LIST); + return Api.fetchFeatureFlagUserList(state.projectId, state.userListIid) + .then(({ data }) => commit(types.RECEIVE_USER_LIST_SUCCESS, data)) + .catch(response => commit(types.RECEIVE_USER_LIST_ERROR, getErrorMessages(response))); +}; + +export const dismissErrorAlert = ({ commit }) => commit(types.DISMISS_ERROR_ALERT); + +export const updateUserList = ({ commit, state }, userList) => { + return Api.updateFeatureFlagUserList(state.projectId, { + iid: userList.iid, + name: userList.name, + }) + .then(({ data }) => redirectTo(data.path)) + .catch(response => commit(types.RECEIVE_USER_LIST_ERROR, getErrorMessages(response))); +}; diff --git a/app/assets/javascripts/user_lists/store/edit/index.js b/app/assets/javascripts/user_lists/store/edit/index.js new file mode 100644 index 00000000000..b30b0b04b9e --- /dev/null +++ b/app/assets/javascripts/user_lists/store/edit/index.js @@ -0,0 +1,11 @@ +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default initialState => + new Vuex.Store({ + actions, + mutations, + state: createState(initialState), + }); diff --git a/app/assets/javascripts/user_lists/store/edit/mutation_types.js b/app/assets/javascripts/user_lists/store/edit/mutation_types.js new file mode 100644 index 00000000000..8b572e36839 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/edit/mutation_types.js @@ -0,0 +1,5 @@ +export const REQUEST_USER_LIST = 'REQUEST_USER_LIST'; +export const RECEIVE_USER_LIST_SUCCESS = 'RECEIVE_USER_LIST_SUCCESS'; +export const RECEIVE_USER_LIST_ERROR = 'RECEIVE_USER_LIST_ERROR'; + +export const DISMISS_ERROR_ALERT = 'DISMISS_ERROR_ALERT'; diff --git a/app/assets/javascripts/user_lists/store/edit/mutations.js b/app/assets/javascripts/user_lists/store/edit/mutations.js new file mode 100644 index 00000000000..8a202885069 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/edit/mutations.js @@ -0,0 +1,19 @@ +import statuses from '../../constants/edit'; +import * as types from './mutation_types'; + +export default { + [types.REQUEST_USER_LIST](state) { + state.status = statuses.LOADING; + }, + [types.RECEIVE_USER_LIST_SUCCESS](state, userList) { + state.status = statuses.SUCCESS; + state.userList = userList; + }, + [types.RECEIVE_USER_LIST_ERROR](state, error) { + state.status = statuses.ERROR; + state.errorMessage = error; + }, + [types.DISMISS_ERROR_ALERT](state) { + state.status = statuses.UNSYNCED; + }, +}; diff --git a/app/assets/javascripts/user_lists/store/edit/state.js b/app/assets/javascripts/user_lists/store/edit/state.js new file mode 100644 index 00000000000..66fbe3c2ba9 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/edit/state.js @@ -0,0 +1,9 @@ +import statuses from '../../constants/edit'; + +export default ({ projectId = '', userListIid = '' }) => ({ + status: statuses.LOADING, + projectId, + userListIid, + userList: null, + errorMessage: [], +}); diff --git a/app/assets/javascripts/user_lists/store/new/actions.js b/app/assets/javascripts/user_lists/store/new/actions.js new file mode 100644 index 00000000000..185508bcfbc --- /dev/null +++ b/app/assets/javascripts/user_lists/store/new/actions.js @@ -0,0 +1,15 @@ +import Api from '~/api'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { getErrorMessages } from '../utils'; +import * as types from './mutation_types'; + +export const dismissErrorAlert = ({ commit }) => commit(types.DISMISS_ERROR_ALERT); + +export const createUserList = ({ commit, state }, userList) => { + return Api.createFeatureFlagUserList(state.projectId, { + ...state.userList, + ...userList, + }) + .then(({ data }) => redirectTo(data.path)) + .catch(response => commit(types.RECEIVE_CREATE_USER_LIST_ERROR, getErrorMessages(response))); +}; diff --git a/app/assets/javascripts/user_lists/store/new/index.js b/app/assets/javascripts/user_lists/store/new/index.js new file mode 100644 index 00000000000..b30b0b04b9e --- /dev/null +++ b/app/assets/javascripts/user_lists/store/new/index.js @@ -0,0 +1,11 @@ +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default initialState => + new Vuex.Store({ + actions, + mutations, + state: createState(initialState), + }); diff --git a/app/assets/javascripts/user_lists/store/new/mutation_types.js b/app/assets/javascripts/user_lists/store/new/mutation_types.js new file mode 100644 index 00000000000..9a5ce6e99f5 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/new/mutation_types.js @@ -0,0 +1,3 @@ +export const RECEIVE_CREATE_USER_LIST_ERROR = 'RECEIVE_CREATE_USER_LIST_ERROR'; + +export const DISMISS_ERROR_ALERT = 'DISMISS_ERROR_ALERT'; diff --git a/app/assets/javascripts/user_lists/store/new/mutations.js b/app/assets/javascripts/user_lists/store/new/mutations.js new file mode 100644 index 00000000000..d7c1276bd72 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/new/mutations.js @@ -0,0 +1,10 @@ +import * as types from './mutation_types'; + +export default { + [types.RECEIVE_CREATE_USER_LIST_ERROR](state, error) { + state.errorMessage = error; + }, + [types.DISMISS_ERROR_ALERT](state) { + state.errorMessage = ''; + }, +}; diff --git a/app/assets/javascripts/user_lists/store/new/state.js b/app/assets/javascripts/user_lists/store/new/state.js new file mode 100644 index 00000000000..0fa73b4ffc1 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/new/state.js @@ -0,0 +1,5 @@ +export default ({ projectId = '' }) => ({ + projectId, + userList: { name: '', user_xids: '' }, + errorMessage: [], +}); diff --git a/app/assets/javascripts/user_lists/store/show/actions.js b/app/assets/javascripts/user_lists/store/show/actions.js new file mode 100644 index 00000000000..15b971aa5e8 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/show/actions.js @@ -0,0 +1,32 @@ +import Api from '~/api'; +import { stringifyUserIds } from '../utils'; +import * as types from './mutation_types'; + +export const fetchUserList = ({ commit, state }) => { + commit(types.REQUEST_USER_LIST); + return Api.fetchFeatureFlagUserList(state.projectId, state.userListIid) + .then(response => commit(types.RECEIVE_USER_LIST_SUCCESS, response.data)) + .catch(() => commit(types.RECEIVE_USER_LIST_ERROR)); +}; + +export const dismissErrorAlert = ({ commit }) => commit(types.DISMISS_ERROR_ALERT); +export const addUserIds = ({ dispatch, commit }, userIds) => { + commit(types.ADD_USER_IDS, userIds); + return dispatch('updateUserList'); +}; + +export const removeUserId = ({ commit, dispatch }, userId) => { + commit(types.REMOVE_USER_ID, userId); + return dispatch('updateUserList'); +}; + +export const updateUserList = ({ commit, state }) => { + commit(types.REQUEST_USER_LIST); + + return Api.updateFeatureFlagUserList(state.projectId, { + ...state.userList, + user_xids: stringifyUserIds(state.userIds), + }) + .then(response => commit(types.RECEIVE_USER_LIST_SUCCESS, response.data)) + .catch(() => commit(types.RECEIVE_USER_LIST_ERROR)); +}; diff --git a/app/assets/javascripts/user_lists/store/show/index.js b/app/assets/javascripts/user_lists/store/show/index.js new file mode 100644 index 00000000000..b30b0b04b9e --- /dev/null +++ b/app/assets/javascripts/user_lists/store/show/index.js @@ -0,0 +1,11 @@ +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default initialState => + new Vuex.Store({ + actions, + mutations, + state: createState(initialState), + }); diff --git a/app/assets/javascripts/user_lists/store/show/mutation_types.js b/app/assets/javascripts/user_lists/store/show/mutation_types.js new file mode 100644 index 00000000000..fb967f06beb --- /dev/null +++ b/app/assets/javascripts/user_lists/store/show/mutation_types.js @@ -0,0 +1,8 @@ +export const REQUEST_USER_LIST = 'REQUEST_USER_LIST'; +export const RECEIVE_USER_LIST_SUCCESS = 'RECEIVE_USER_LIST_SUCCESS'; +export const RECEIVE_USER_LIST_ERROR = 'RECEIVE_USER_LIST_ERROR'; + +export const DISMISS_ERROR_ALERT = 'DISMISS_ERROR_ALERT'; + +export const ADD_USER_IDS = 'ADD_USER_IDS'; +export const REMOVE_USER_ID = 'REMOVE_USER_ID'; diff --git a/app/assets/javascripts/user_lists/store/show/mutations.js b/app/assets/javascripts/user_lists/store/show/mutations.js new file mode 100644 index 00000000000..c3e766465a7 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/show/mutations.js @@ -0,0 +1,29 @@ +import { states } from '../../constants/show'; +import * as types from './mutation_types'; +import { parseUserIds } from '../utils'; + +export default { + [types.REQUEST_USER_LIST](state) { + state.state = states.LOADING; + }, + [types.RECEIVE_USER_LIST_SUCCESS](state, userList) { + state.state = states.SUCCESS; + state.userIds = userList.user_xids?.length > 0 ? parseUserIds(userList.user_xids) : []; + state.userList = userList; + }, + [types.RECEIVE_USER_LIST_ERROR](state) { + state.state = states.ERROR; + }, + [types.DISMISS_ERROR_ALERT](state) { + state.state = states.ERROR_DISMISSED; + }, + [types.ADD_USER_IDS](state, ids) { + state.userIds = [ + ...state.userIds, + ...parseUserIds(ids).filter(id => id && !state.userIds.includes(id)), + ]; + }, + [types.REMOVE_USER_ID](state, id) { + state.userIds = state.userIds.filter(uid => uid !== id); + }, +}; diff --git a/app/assets/javascripts/user_lists/store/show/state.js b/app/assets/javascripts/user_lists/store/show/state.js new file mode 100644 index 00000000000..a5780893ccb --- /dev/null +++ b/app/assets/javascripts/user_lists/store/show/state.js @@ -0,0 +1,9 @@ +import { states } from '../../constants/show'; + +export default ({ projectId = '', userListIid = '' }) => ({ + state: states.LOADING, + projectId, + userListIid, + userIds: [], + userList: null, +}); diff --git a/app/assets/javascripts/user_lists/store/utils.js b/app/assets/javascripts/user_lists/store/utils.js new file mode 100644 index 00000000000..f4e46947759 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/utils.js @@ -0,0 +1,5 @@ +export const parseUserIds = userIds => userIds.split(/\s*,\s*/g); + +export const stringifyUserIds = userIds => userIds.join(','); + +export const getErrorMessages = error => [].concat(error?.response?.data?.message ?? error.message); diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index c8f95dac48e..3521c1a105f 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import { sanitize } from 'dompurify'; +import { sanitize } from '~/lib/dompurify'; import UsersCache from './lib/utils/users_cache'; import UserPopover from './vue_shared/components/user_popover/user_popover.vue'; @@ -42,6 +42,7 @@ const populateUserInfo = user => { bio: userData.bio, bioHtml: sanitize(userData.bio_html), workInformation: userData.work_information, + websiteUrl: userData.website_url, loaded: true, }); } diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 5f4260f26ff..20d1a3c1fcd 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -19,6 +19,7 @@ import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; window.emitSidebarEvent = window.emitSidebarEvent || $.noop; function UsersSelect(currentUser, els, options = {}) { + const elsClassName = els?.toString().match('.(.+$)')[1]; const $els = $(els || '.js-user-search'); this.users = this.users.bind(this); this.user = this.user.bind(this); @@ -127,9 +128,16 @@ function UsersSelect(currentUser, els, options = {}) { .find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`); firstSelected.remove(); - emitSidebarEvent('sidebar.removeAssignee', { - id: firstSelectedId, - }); + + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.removeReviewer', { + id: firstSelectedId, + }); + } else { + emitSidebarEvent('sidebar.removeAssignee', { + id: firstSelectedId, + }); + } } } }; @@ -392,7 +400,11 @@ function UsersSelect(currentUser, els, options = {}) { defaultLabel, hidden() { if ($dropdown.hasClass('js-multiselect')) { - emitSidebarEvent('sidebar.saveAssignees'); + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.saveReviewers'); + } else { + emitSidebarEvent('sidebar.saveAssignees'); + } } if (!$dropdown.data('alwaysShowSelectbox')) { @@ -428,10 +440,18 @@ function UsersSelect(currentUser, els, options = {}) { previouslySelected.each((index, element) => { element.remove(); }); - emitSidebarEvent('sidebar.removeAllAssignees'); + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.removeAllReviewers'); + } else { + emitSidebarEvent('sidebar.removeAllAssignees'); + } } else if (isActive) { // user selected - emitSidebarEvent('sidebar.addAssignee', user); + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.addReviewer', user); + } else { + emitSidebarEvent('sidebar.addAssignee', user); + } // Remove unassigned selection (if it was previously selected) const unassignedSelected = $dropdown @@ -448,7 +468,11 @@ function UsersSelect(currentUser, els, options = {}) { } // User unselected - emitSidebarEvent('sidebar.removeAssignee', user); + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.removeReviewer', user); + } else { + emitSidebarEvent('sidebar.removeAssignee', user); + } } if (getSelected().find(u => u === gon.current_user_id)) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue index 208df03b6a4..b90cbfd1a1a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue @@ -74,9 +74,6 @@ export default { canBeManuallyRedeployed() { return this.computedDeploymentStatus === FAILED && Boolean(this.redeployPath); }, - shouldShowManualButtons() { - return this.glFeatures.deployFromFooter; - }, hasExternalUrls() { return Boolean(this.deployment.external_url && this.deployment.external_url_formatted); }, @@ -154,7 +151,7 @@ export default { <template> <div> <deployment-action-button - v-if="shouldShowManualButtons && canBeManuallyDeployed" + v-if="canBeManuallyDeployed" :action-in-progress="actionInProgress" :actions-configuration="$options.actionsConfiguration[constants.DEPLOYING]" :computed-deployment-status="computedDeploymentStatus" @@ -165,7 +162,7 @@ export default { <span>{{ $options.actionsConfiguration[constants.DEPLOYING].buttonText }}</span> </deployment-action-button> <deployment-action-button - v-if="shouldShowManualButtons && canBeManuallyRedeployed" + v-if="canBeManuallyRedeployed" :action-in-progress="actionInProgress" :actions-configuration="$options.actionsConfiguration[constants.REDEPLOYING]" :computed-deployment-status="computedDeploymentStatus" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue index 157d6d60290..e3c0b7935d7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue @@ -72,12 +72,7 @@ export default { css-class="deploy-link js-deploy-url inline" /> <gl-dropdown size="small" class="js-mr-wigdet-deployment-dropdown"> - <gl-search-box-by-type - v-model.trim="searchTerm" - v-autofocusonshow - autofocus - class="gl-m-3" - /> + <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus /> <gl-dropdown-item v-for="change in filteredChanges" :key="change.path" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue index 6b3007fce51..c762922d890 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue @@ -1,5 +1,5 @@ <script> -import tooltip from '~/vue_shared/directives/tooltip'; +import { GlTooltipDirective } from '@gitlab/ui'; import MrWidgetAuthor from './mr_widget_author.vue'; export default { @@ -8,7 +8,7 @@ export default { MrWidgetAuthor, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { actionText: { @@ -34,6 +34,7 @@ export default { <h4 class="js-mr-widget-author"> {{ actionText }} <mr-widget-author :author="author" /> - <time v-tooltip :title="dateTitle" data-container="body"> {{ dateReadable }} </time> + <span class="sr-only">{{ dateReadable }} ({{ dateTitle }})</span> + <time v-gl-tooltip.hover aria-hidden :title="dateTitle"> {{ dateReadable }} </time> </h4> </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 814d4e8341e..eb8989adb2a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -111,9 +111,10 @@ export default { v-html="mr.sourceBranchLink" /><clipboard-button ref="copyBranchNameButton" + data-testid="mr-widget-copy-clipboard" :text="branchNameClipboardData" :title="__('Copy branch name')" - css-class="btn-default btn-transparent btn-clipboard" + category="tertiary" /> {{ s__('mrWidget|into') }} <tooltip-on-truncate diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue index 859f2c57598..c917b69953f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue @@ -1,6 +1,5 @@ <script> -import { GlIcon, GlSprintf } from '@gitlab/ui'; -import tooltip from '../../vue_shared/directives/tooltip'; +import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '../../locale'; export default { @@ -13,7 +12,7 @@ export default { GlSprintf, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, }; </script> @@ -28,7 +27,7 @@ export default { </gl-sprintf> </span> <gl-icon - v-tooltip + v-gl-tooltip.hover :title="$options.i18n.tooltipTitle" :aria-label="$options.i18n.tooltipTitle" name="question-o" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue index 7ddcdd49df5..29c26f4fb3e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue @@ -1,9 +1,11 @@ <script> +import { GlButton } from '@gitlab/ui'; import statusIcon from '../mr_widget_status_icon.vue'; export default { name: 'MRWidgetArchived', components: { + GlButton, statusIcon, }, }; @@ -12,9 +14,9 @@ export default { <div class="mr-widget-body media"> <div class="space-children"> <status-icon status="warning" /> - <button type="button" class="btn btn-success btn-sm" disabled="true"> + <gl-button category="secondary" variant="success" :disabled="true"> {{ s__('mrWidget|Merge') }} - </button> + </gl-button> </div> <div class="media-body"> <span class="bold"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index 83e7d6db9fa..30da9947859 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton } from '@gitlab/ui'; import eventHub from '../../event_hub'; import statusIcon from '../mr_widget_status_icon.vue'; @@ -8,6 +8,7 @@ export default { components: { statusIcon, GlLoadingIcon, + GlButton, }, props: { mr: { @@ -33,20 +34,21 @@ export default { <template> <div class="mr-widget-body media"> <status-icon status="warning" /> - <div class="media-body space-children"> + <div class="media-body space-children gl-display-flex gl-flex-wrap gl-align-items-center"> <span class="bold"> <template v-if="mr.mergeError">{{ mr.mergeError }}</template> {{ s__('mrWidget|This merge request failed to be merged automatically') }} </span> - <button + <gl-button :disabled="isRefreshing" - type="button" - class="btn btn-sm btn-default" + category="secondary" + variant="default" + size="small" @click="refreshWidget" > <gl-loading-icon v-if="isRefreshing" :inline="true" /> {{ s__('mrWidget|Refresh') }} - </button> + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 58839251edc..17cd740ddd9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -1,8 +1,7 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ -import { GlLoadingIcon, GlButton } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { deprecatedCreateFlash as Flash } from '~/flash'; -import tooltip from '~/vue_shared/directives/tooltip'; import { s__, __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import MrWidgetAuthorTime from '../mr_widget_author_time.vue'; @@ -12,7 +11,7 @@ import eventHub from '../../event_hub'; export default { name: 'MRWidgetMerged', directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { MrWidgetAuthorTime, @@ -115,7 +114,7 @@ export default { /> <gl-button v-if="mr.canRevertInCurrentMR" - v-tooltip + v-gl-tooltip.hover :title="revertTitle" size="small" category="secondary" @@ -128,7 +127,7 @@ export default { </gl-button> <gl-button v-else-if="mr.revertInForkPath" - v-tooltip + v-gl-tooltip.hover :href="mr.revertInForkPath" :title="revertTitle" size="small" @@ -140,7 +139,7 @@ export default { </gl-button> <gl-button v-if="mr.canCherryPickInCurrentMR" - v-tooltip + v-gl-tooltip.hover :title="cherryPickTitle" size="small" href="#modal-cherry-pick-commit" @@ -151,7 +150,7 @@ export default { </gl-button> <gl-button v-else-if="mr.cherryPickInForkPath" - v-tooltip + v-gl-tooltip.hover :href="mr.cherryPickInForkPath" :title="cherryPickTitle" size="small" @@ -177,7 +176,9 @@ export default { <clipboard-button :title="__('Copy commit SHA')" :text="mr.mergeCommitSha" - css-class="btn-default btn-transparent btn-clipboard js-mr-merged-copy-sha" + css-class="js-mr-merged-copy-sha" + category="tertiary" + size="small" /> </template> </p> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue index 83783528cc1..6489569cf68 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue @@ -1,13 +1,12 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; import statusIcon from '../mr_widget_status_icon.vue'; export default { name: 'MRWidgetMissingBranch', directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlIcon, @@ -52,7 +51,7 @@ export default { <span class="bold js-branch-text"> <span class="capitalize"> {{ missingBranchName }} </span> {{ s__('mrWidget|branch does not exist.') }} {{ missingBranchNameMessage }} - <gl-icon v-tooltip :title="message" :aria-label="message" name="question-o" /> + <gl-icon v-gl-tooltip :title="message" :aria-label="message" name="question-o" /> </span> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index ec0934c5b4b..14c2e9fa828 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -1,6 +1,6 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { escape } from 'lodash'; import simplePoll from '../../../lib/utils/simple_poll'; import eventHub from '../../event_hub'; @@ -12,7 +12,7 @@ export default { name: 'MRWidgetRebase', components: { statusIcon, - GlLoadingIcon, + GlButton, }, props: { mr: { @@ -109,29 +109,29 @@ export default { <div class="rebase-state-find-class-convention media media-body space-children"> <template v-if="mr.rebaseInProgress || isMakingRequest"> - <span class="bold">{{ __('Rebase in progress') }}</span> + <span class="bold" data-testid="rebase-message">{{ __('Rebase in progress') }}</span> </template> <template v-if="!mr.rebaseInProgress && !mr.canPushToSourceBranch"> - <span class="bold" v-html="fastForwardMergeText"></span> + <span class="bold" data-testid="rebase-message" v-html="fastForwardMergeText"></span> </template> <template v-if="!mr.rebaseInProgress && mr.canPushToSourceBranch && !isMakingRequest"> <div class="accept-merge-holder clearfix js-toggle-container accept-action media space-children" > - <button - :disabled="isMakingRequest" - type="button" - class="btn btn-sm btn-reopen btn-success qa-mr-rebase-button" + <gl-button + :loading="isMakingRequest" + variant="success" + class="qa-mr-rebase-button" @click="rebase" > - <gl-loading-icon v-if="isMakingRequest" />{{ __('Rebase') }} - </button> - <span v-if="!rebasingError" class="bold">{{ + {{ __('Rebase') }} + </gl-button> + <span v-if="!rebasingError" class="bold" data-testid="rebase-message">{{ __( 'Fast-forward merge is not possible. Rebase the source branch onto the target branch.', ) }}</span> - <span v-else class="bold danger">{{ rebasingError }}</span> + <span v-else class="bold danger" data-testid="rebase-message">{{ rebasingError }}</span> </div> </template> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 240bab58297..835f7b9e9a9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -1,12 +1,9 @@ <script> -/* eslint-disable vue/no-v-html */ import { isEmpty } from 'lodash'; import { GlIcon, GlButton, GlSprintf, GlLink } from '@gitlab/ui'; -import successSvg from 'icons/_icon_status_success.svg'; -import warningSvg from 'icons/_icon_status_warning.svg'; import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; import simplePoll from '~/lib/utils/simple_poll'; -import { __, sprintf } from '~/locale'; +import { __ } from '~/locale'; import MergeRequest from '../../../merge_request'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { deprecatedCreateFlash as Flash } from '../../../flash'; @@ -59,8 +56,6 @@ export default { commitMessage: this.mr.commitMessage, squashBeforeMerge: this.mr.squashIsSelected, isSquashReadOnly: this.mr.squashIsReadonly, - successSvg, - warningSvg, squashCommitMessage: this.mr.squashCommitMessage, }; }, @@ -147,16 +142,7 @@ export default { return !this.mr.ffOnlyEnabled; }, shaMismatchLink() { - const href = this.mr.mergeRequestDiffsPath; - - return sprintf( - __('New changes were added. %{linkStart}Reload the page to review them%{linkEnd}'), - { - linkStart: `<a href="${href}">`, - linkEnd: '</a>', - }, - false, - ); + return this.mr.mergeRequestDiffsPath; }, }, methods: { @@ -331,7 +317,7 @@ export default { @click.prevent="handleMergeButtonClick(true)" > <span class="media"> - <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span> + <gl-icon name="status_success" class="merge-opt-icon" aria-hidden="true" /> <span class="media-body merge-opt-title">{{ autoMergeText }}</span> </span> </a> @@ -349,7 +335,7 @@ export default { @click.prevent="handleMergeImmediatelyButtonClick" > <span class="media"> - <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span> + <gl-icon name="status_warning" class="merge-opt-icon" aria-hidden="true" /> <span class="media-body merge-opt-title">{{ __('Merge immediately') }}</span> </span> </a> @@ -400,7 +386,17 @@ export default { </div> <div v-if="mr.isSHAMismatch" class="d-flex align-items-center mt-2 js-sha-mismatch"> <gl-icon name="warning-solid" class="text-warning mr-1" /> - <span class="text-warning" v-html="shaMismatchLink"></span> + <span class="text-warning"> + <gl-sprintf + :message=" + __('New changes were added. %{linkStart}Reload the page to review them%{linkEnd}') + " + > + <template #link="{ content }"> + <gl-link :href="mr.mergeRequestDiffsPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> </div> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index 6608381f348..ff0d065c71d 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,14 +1,16 @@ <script> -import { GlIcon } from '@gitlab/ui'; -import tooltip from '~/vue_shared/directives/tooltip'; -import { __ } from '~/locale'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { SQUASH_BEFORE_MERGE } from '../../i18n'; export default { components: { GlIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, + }, + i18n: { + ...SQUASH_BEFORE_MERGE, }, props: { value: { @@ -28,7 +30,10 @@ export default { }, computed: { tooltipTitle() { - return this.isDisabled ? __('Required in this project.') : false; + return this.isDisabled ? this.$options.i18n.tooltipTitle : null; + }, + tooltipFocusable() { + return this.isDisabled ? '0' : null; }, }, }; @@ -37,10 +42,11 @@ export default { <template> <div class="inline"> <label - v-tooltip + v-gl-tooltip :class="{ 'gl-text-gray-400': isDisabled }" + :tabindex="tooltipFocusable" data-testid="squashLabel" - :data-title="tooltipTitle" + :title="tooltipTitle" > <input :checked="value" @@ -50,19 +56,20 @@ export default { class="qa-squash-checkbox js-squash-checkbox" @change="$emit('input', $event.target.checked)" /> - {{ __('Squash commits') }} + {{ $options.i18n.checkboxLabel }} </label> <a v-if="helpPath" - v-tooltip + v-gl-tooltip :href="helpPath" - data-title="About this feature" - data-placement="bottom" + :title="$options.i18n.helpLabel" target="_blank" rel="noopener noreferrer nofollow" - data-container="body" > <gl-icon name="question" /> + <span class="sr-only"> + {{ $options.i18n.helpLabel }} + </span> </a> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index 61cc950f058..eba3d50fdc9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -3,13 +3,13 @@ import $ from 'jquery'; import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; +import MergeRequest from '~/merge_request'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import getStateQuery from '../../queries/get_state.query.graphql'; import workInProgressQuery from '../../queries/states/work_in_progress.query.graphql'; import removeWipMutation from '../../queries/toggle_wip.mutation.graphql'; import StatusIcon from '../mr_widget_status_icon.vue'; -import tooltip from '../../../vue_shared/directives/tooltip'; import eventHub from '../../event_hub'; export default { @@ -18,9 +18,6 @@ export default { StatusIcon, GlButton, }, - directives: { - tooltip, - }, mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], apollo: { userPermissions: { @@ -128,8 +125,7 @@ export default { .then(res => res.data) .then(data => { eventHub.$emit('UpdateWidgetData', data); - createFlash(__('The merge request can now be merged.'), 'notice'); - $('.merge-request .detail-page-description .title').text(this.mr.title); + MergeRequest.toggleDraftStatus(this.mr.title, true); }) .catch(() => { this.isMakingRequest = false; diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js new file mode 100644 index 00000000000..e8e522a01e9 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js @@ -0,0 +1,7 @@ +import { __ } from '~/locale'; + +export const SQUASH_BEFORE_MERGE = { + tooltipTitle: __('Required in this project.'), + checkboxLabel: __('Squash commits'), + helpLabel: __('What is squashing?'), +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 43ce748b41d..46749fc5e87 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 @@ -4,6 +4,7 @@ import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_ import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue'; import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; +import { GlSafeHtmlDirective } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; @@ -45,12 +46,16 @@ import GroupedTestReportsApp from '../reports/components/grouped_test_reports_ap import { setFaviconOverlay } from '../lib/utils/common_utils'; import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue'; import getStateQuery from './queries/get_state.query.graphql'; +import { isExperimentEnabled } from '~/lib/utils/experimentation'; export default { el: '#js-vue-mr-widget', // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 // eslint-disable-next-line @gitlab/require-i18n-strings name: 'MRWidget', + directives: { + SafeHtml: GlSafeHtmlDirective, + }, components: { Loading, 'mr-widget-header': WidgetHeader, @@ -85,6 +90,7 @@ export default { TerraformPlan, GroupedAccessibilityReportsApp, MrWidgetApprovals, + SecurityReportsApp: () => import('~/vue_shared/security_reports/security_reports_app.vue'), }, apollo: { state: { @@ -148,7 +154,7 @@ export default { }, shouldSuggestPipelines() { return ( - gon.features?.suggestPipeline && + isExperimentEnabled('suggestPipeline') && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath && !this.mr.isDismissedSuggestPipeline @@ -178,6 +184,9 @@ export default { this.mr.mergePipelinesEnabled && this.mr.sourceProjectId !== this.mr.targetProjectId, ); }, + shouldRenderSecurityReport() { + return Boolean(window.gon?.features?.coreSecurityMrWidget && this.mr.pipeline.id); + }, mergeError() { let { mergeError } = this.mr; @@ -455,6 +464,13 @@ export default { :codequality-help-path="mr.codequalityHelpPath" /> + <security-reports-app + v-if="shouldRenderSecurityReport" + :pipeline-id="mr.pipeline.id" + :project-id="mr.targetProjectId" + :security-reports-docs-path="mr.securityReportsDocsPath" + /> + <grouped-test-reports-app v-if="mr.testResultsPath" class="js-reports-container" @@ -498,7 +514,7 @@ export default { </mr-widget-alert-message> <mr-widget-alert-message v-if="mr.mergeError" type="danger"> - {{ mergeError }} + <span v-safe-html="mergeError"></span> </mr-widget-alert-message> <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" /> 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 846b1c453a1..8b235b20ad4 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 @@ -232,6 +232,7 @@ export default class MergeRequestStore { this.userCalloutsPath = data.user_callouts_path; this.suggestPipelineFeatureId = data.suggest_pipeline_feature_id; this.isDismissedSuggestPipeline = data.is_dismissed_suggest_pipeline; + this.securityReportsDocsPath = data.security_reports_docs_path; // codeclimate const blobPath = data.blob_path || {}; diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue index f333ab49ead..9b21de19185 100644 --- a/app/assets/javascripts/vue_shared/components/actions_button.vue +++ b/app/assets/javascripts/vue_shared/components/actions_button.vue @@ -3,7 +3,7 @@ import { GlDropdown, GlDropdownItem, GlDropdownDivider, - GlLink, + GlButton, GlTooltipDirective, } from '@gitlab/ui'; @@ -12,7 +12,7 @@ export default { GlDropdown, GlDropdownItem, GlDropdownDivider, - GlLink, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -27,6 +27,16 @@ export default { required: false, default: '', }, + category: { + type: String, + required: false, + default: 'secondary', + }, + variant: { + type: String, + required: false, + default: 'default', + }, }, computed: { hasMultipleActions() { @@ -54,6 +64,8 @@ export default { class="gl-button-deprecated-adapter" :text="selectedAction.text" :split-href="selectedAction.href" + :variant="variant" + :category="category" split @click="handleClick(selectedAction, $event)" > @@ -77,14 +89,15 @@ export default { <gl-dropdown-divider v-if="index != actions.length - 1" :key="action.key + '_divider'" /> </template> </gl-dropdown> - <gl-link + <gl-button v-else-if="selectedAction" v-gl-tooltip="selectedAction.tooltip" v-bind="selectedAction.attrs" - class="btn" + :variant="variant" + :category="category" :href="selectedAction.href" @click="handleClick(selectedAction, $event)" > {{ selectedAction.text }} - </gl-link> + </gl-button> </template> diff --git a/app/assets/javascripts/vue_shared/components/alert_details_table.vue b/app/assets/javascripts/vue_shared/components/alert_details_table.vue index c94e784c01e..34f6d384f7b 100644 --- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue +++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue @@ -1,20 +1,38 @@ <script> import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { reduce } from 'lodash'; import { s__ } from '~/locale'; import { capitalizeFirstCharacter, convertToSentenceCase, splitCamelCase, } from '~/lib/utils/text_utility'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const thClass = 'gl-bg-transparent! gl-border-1! gl-border-b-solid! gl-border-gray-200!'; const tdClass = 'gl-border-gray-100! gl-p-5!'; +const allowedFields = [ + 'iid', + 'title', + 'severity', + 'status', + 'startedAt', + 'eventCount', + 'monitoringTool', + 'service', + 'description', + 'endedAt', + 'details', + 'hosts', +]; + export default { components: { GlLoadingIcon, GlTable, }, + mixins: [glFeatureFlagsMixin()], props: { alert: { type: Object, @@ -42,14 +60,37 @@ export default { }, ], computed: { + flaggedAllowedFields() { + return this.shouldDisplayEnvironment ? [...allowedFields, 'environment'] : allowedFields; + }, items() { if (!this.alert) { return []; } - return Object.entries(this.alert).map(([fieldName, value]) => ({ - fieldName, - value, - })); + return reduce( + this.alert, + (allowedItems, fieldValue, fieldName) => { + if (this.isAllowed(fieldName)) { + let value; + if (fieldName === 'environment') { + value = fieldValue?.name; + } else { + value = fieldValue; + } + return [...allowedItems, { fieldName, value }]; + } + return allowedItems; + }, + [], + ); + }, + shouldDisplayEnvironment() { + return this.glFeatures.exposeEnvironmentPathInAlertDetails; + }, + }, + methods: { + isAllowed(fieldName) { + return this.flaggedAllowedFields.includes(fieldName); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index e1f54b62223..2e4b9b9a135 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ import { groupBy } from 'lodash'; -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; import tooltip from '~/vue_shared/directives/tooltip'; import { glEmojiTag } from '../../emoji'; import { __, sprintf } from '~/locale'; @@ -12,6 +12,7 @@ const NO_USER_ID = -1; export default { components: { GlIcon, + GlLoadingIcon, }, directives: { tooltip, @@ -184,10 +185,7 @@ export default { <span class="award-control-icon award-control-icon-super-positive"> <gl-icon aria-hidden="true" name="smiley" /> </span> - <i - aria-hidden="true" - class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading" - ></i> + <gl-loading-icon size="md" color="dark" class="award-control-icon-loading" /> </button> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js index 9e2b3097499..7a76888c916 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js @@ -1,9 +1,5 @@ -import { - SNIPPET_MARK_VIEW_APP_START, - SNIPPET_MARK_BLOBS_CONTENT, - SNIPPET_MEASURE_BLOBS_CONTENT, - SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP, -} from '~/performance_constants'; +import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance_constants'; +import eventHub from '~/blob/components/eventhub'; export default { props: { @@ -17,12 +13,6 @@ export default { }, }, mounted() { - window.requestAnimationFrame(() => { - if (!performance.getEntriesByName(SNIPPET_MARK_BLOBS_CONTENT).length) { - performance.mark(SNIPPET_MARK_BLOBS_CONTENT); - performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT); - performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP, SNIPPET_MARK_VIEW_APP_START); - } - }); + eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT); }, }; diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index d7af3b3298e..1b7e51b7d02 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -7,7 +7,7 @@ import CiIcon from './ci_icon.vue'; * * Receives status object containing: * status: { - * details_path: "/gitlab-org/gitlab-foss/pipelines/8150156" // url + * details_path or detailsPath: "/gitlab-org/gitlab-foss/pipelines/8150156" // url * group:"running" // used for CSS class * icon: "icon_status_running" // used to render the icon * label:"running" // used for potential tooltip @@ -46,6 +46,13 @@ export default { }, }, computed: { + title() { + return !this.showText ? this.status?.text : ''; + }, + detailsPath() { + // For now, this can either come from graphQL with camelCase or REST API in snake_case + return this.status.detailsPath || this.status.details_path; + }, cssClass() { const className = this.status.group; return className ? `ci-status ci-${className} qa-status-badge` : 'ci-status qa-status-badge'; @@ -54,12 +61,7 @@ export default { }; </script> <template> - <a - v-gl-tooltip - :href="status.details_path" - :class="cssClass" - :title="!showText ? status.text : ''" - > + <a v-gl-tooltip :href="detailsPath" :class="cssClass" :title="title"> <ci-icon :status="status" :css-classes="iconClasses" /> <template v-if="showText"> diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index 0234b6bf848..960551fae91 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -12,7 +12,7 @@ * css-class="btn-transparent" * /> */ -import { GlDeprecatedButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; export default { name: 'ClipboardButton', @@ -20,8 +20,7 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - GlDeprecatedButton, - GlIcon, + GlButton, }, props: { text: { @@ -50,7 +49,17 @@ export default { cssClass: { type: String, required: false, - default: 'btn-default', + default: null, + }, + category: { + type: String, + required: false, + default: 'secondary', + }, + size: { + type: String, + required: false, + default: 'medium', }, }, computed: { @@ -65,13 +74,15 @@ export default { </script> <template> - <gl-deprecated-button + <gl-button v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }" v-gl-tooltip.hover.blur :class="cssClass" :title="title" :data-clipboard-text="clipboardText" - > - <gl-icon name="copy-to-clipboard" /> - </gl-deprecated-button> + :category="category" + :size="size" + icon="copy-to-clipboard" + :aria-label="__('Copy this value')" + /> </template> diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index c1c8fb3a6e2..e01a651806d 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -139,7 +139,7 @@ export default { <template> <div class="branch-commit cgray"> <template v-if="shouldShowRefInfo"> - <div class="icon-container"> + <div class="icon-container gl-display-inline-block"> <gl-icon v-if="tag" name="tag" /> <gl-icon v-else-if="mergeRequestRef" name="git-merge" /> <gl-icon v-else name="branch" /> diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue index e7f6cc1abc0..a42a606d446 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue @@ -12,6 +12,11 @@ export default { type: String, required: true, }, + handleSubmit: { + type: Function, + required: false, + default: null, + }, }, data() { return { @@ -41,7 +46,11 @@ export default { this.$refs.modal.hide(); }, submitModal() { - this.$refs.form.submit(); + if (this.handleSubmit) { + this.handleSubmit(this.path); + } else { + this.$refs.form.submit(); + } }, }, csrf, diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue index fe488ab6cfa..5ac30424f98 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue @@ -4,6 +4,11 @@ import ImageViewer from './viewers/image_viewer.vue'; import DownloadViewer from './viewers/download_viewer.vue'; export default { + components: { + MarkdownViewer, + ImageViewer, + DownloadViewer, + }, props: { content: { type: String, @@ -45,35 +50,25 @@ export default { default: () => ({}), }, }, - computed: { - viewer() { - if (!this.path) return null; - if (!this.type) return DownloadViewer; - - switch (this.type) { - case 'markdown': - return MarkdownViewer; - case 'image': - return ImageViewer; - default: - return DownloadViewer; - } - }, - }, }; </script> <template> <div class="preview-container"> - <component - :is="viewer" - :path="path" + <image-viewer v-if="type === 'image'" :path="path" :file-size="fileSize" /> + <markdown-viewer + v-if="type === 'markdown'" + :content="content" + :commit-sha="commitSha" :file-path="filePath" - :file-size="fileSize" :project-path="projectPath" - :content="content" :images="images" - :commit-sha="commitSha" + /> + <download-viewer + v-if="!type && path" + :path="path" + :file-path="filePath" + :file-size="fileSize" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue index f9d3d76e7f5..8d55701f499 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue @@ -1,10 +1,9 @@ <script> -import { GlLink, GlIcon } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; import { numberToHumanSize } from '../../../../lib/utils/number_utils'; export default { components: { - GlLink, GlIcon, }, props: { @@ -44,16 +43,10 @@ export default { ({{ fileSizeReadable }}) </template> </p> - <gl-link - :href="path" - class="btn btn-default" - rel="nofollow" - :download="fileName" - target="_blank" - > + <a :href="path" class="btn btn-default" rel="nofollow" :download="fileName" target="_blank"> <gl-icon :size="16" name="download" class="float-left gl-mr-3" /> {{ __('Download') }} - </gl-link> + </a> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue index 543547b37fe..c4bce860ae4 100644 --- a/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue +++ b/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue @@ -1,5 +1,6 @@ <script> import $ from 'jquery'; +import { GlButton } from '@gitlab/ui'; const buttonVariants = ['danger', 'primary', 'success', 'warning']; const sizeVariants = ['sm', 'md', 'lg', 'xl']; @@ -7,6 +8,9 @@ const sizeVariants = ['sm', 'md', 'lg', 'xl']; export default { name: 'DeprecatedModal2', // use GlModal instead + components: { + GlButton, + }, props: { id: { type: String, @@ -72,20 +76,21 @@ export default { <div :id="id" class="modal fade" tabindex="-1" role="dialog"> <div :class="modalSizeClass" class="modal-dialog" role="document"> <div class="modal-content"> - <div class="modal-header"> + <div class="modal-header gl-pr-4"> <slot name="header"> <h4 class="modal-title"> <slot name="title"> {{ headerTitleText }} </slot> </h4> - <button + <gl-button :aria-label="s__('Modal|Close')" - type="button" - class="close js-modal-close-action" + variant="default" + category="tertiary" + size="small" + icon="close" + class="js-modal-close-action" data-dismiss="modal" @click="emitCancel($event)" - > - <span aria-hidden="true">×</span> - </button> + /> </slot> </div> @@ -93,23 +98,21 @@ export default { <div class="modal-footer"> <slot name="footer"> - <button - type="button" - class="btn js-modal-cancel-action qa-modal-cancel-button" + <gl-button + class="js-modal-cancel-action qa-modal-cancel-button" data-dismiss="modal" @click="emitCancel($event)" > {{ s__('Modal|Cancel') }} - </button> - <button + </gl-button> + <gl-button :class="`btn-${footerPrimaryButtonVariant}`" - type="button" - class="btn js-modal-primary-action qa-modal-primary-button" + class="js-modal-primary-action qa-modal-primary-button" data-dismiss="modal" @click="emitSubmit($event)" > {{ footerPrimaryButtonText }} - </button> + </gl-button> </slot> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue index c7d7c3a1d24..2a28b13e7bf 100644 --- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue +++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue @@ -22,7 +22,7 @@ export default { }, data() { return { - isDismissed: 'false', + isDismissed: false, }; }, computed: { @@ -30,12 +30,12 @@ export default { return `${slugifyWithUnderscore(this.featureName)}_feedback_dismissed`; }, showAlert() { - return this.isDismissed === 'false'; + return !this.isDismissed; }, }, methods: { dismissFeedbackAlert() { - this.isDismissed = 'true'; + this.isDismissed = true; }, }, }; @@ -43,16 +43,12 @@ export default { <template> <div v-show="showAlert"> - <local-storage-sync - :value="isDismissed" - :storage-key="storageKey" - @input="dismissFeedbackAlert" - /> + <local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json /> <gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissFeedbackAlert"> <gl-sprintf :message=" __( - 'We’ve been making changes to %{featureName} and we’d love your feedback %{linkStart}in this issue%{linkEnd} to help us improve the experience.', + 'Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience.', ) " > diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue index 7157337f8f3..48b94fdc181 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue @@ -1,7 +1,11 @@ <script> +import { GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; export default { + components: { + GlIcon, + }, props: { placeholderText: { type: String, @@ -40,6 +44,6 @@ export default { type="search" autocomplete="off" /> - <i class="fa fa-search dropdown-input-search" aria-hidden="true" data-hidden="true"> </i> + <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" data-hidden="true" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue deleted file mode 100644 index 4d85726065b..00000000000 --- a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue +++ /dev/null @@ -1,92 +0,0 @@ -<script> -import { GlDeprecatedButton, GlIcon } from '@gitlab/ui'; - -export default { - components: { - GlIcon, - GlDeprecatedButton, - }, - props: { - size: { - type: String, - required: false, - default: '', - }, - primaryButtonClass: { - type: String, - required: false, - default: '', - }, - dropdownClass: { - type: String, - required: false, - default: '', - }, - actions: { - type: Array, - required: true, - }, - defaultAction: { - type: Number, - required: true, - }, - }, - data() { - return { - selectedAction: this.defaultAction, - }; - }, - computed: { - selectedActionTitle() { - return this.actions[this.selectedAction].title; - }, - buttonSizeClass() { - return `btn-${this.size}`; - }, - }, - methods: { - handlePrimaryActionClick() { - this.$emit('onActionClick', this.actions[this.selectedAction]); - }, - handleActionClick(selectedAction) { - this.selectedAction = selectedAction; - this.$emit('onActionSelect', selectedAction); - }, - }, -}; -</script> - -<template> - <div class="btn-group droplab-dropdown comment-type-dropdown"> - <gl-deprecated-button - :class="primaryButtonClass" - :size="size" - @click.prevent="handlePrimaryActionClick" - > - {{ selectedActionTitle }} - </gl-deprecated-button> - <button - :class="buttonSizeClass" - type="button" - class="btn dropdown-toggle pl-2 pr-2" - data-display="static" - data-toggle="dropdown" - > - <gl-icon name="chevron-down" :aria-label="__('toggle dropdown')" /> - </button> - <ul :class="dropdownClass" class="dropdown-menu dropdown-open-top"> - <template v-for="(action, index) in actions"> - <li :key="index" :class="{ 'droplab-item-selected': selectedAction === index }"> - <gl-deprecated-button class="btn-transparent" @click.prevent="handleActionClick(index)"> - <i aria-hidden="true" class="fa fa-check icon"> </i> - <div class="description"> - <strong>{{ action.title }}</strong> - <p>{{ action.description }}</p> - </div> - </gl-deprecated-button> - </li> - <li v-if="index === 0" :key="`${index}-separator`" class="divider droplab-item-ignore"></li> - </template> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/editor_lite.vue b/app/assets/javascripts/vue_shared/components/editor_lite.vue new file mode 100644 index 00000000000..cfe3ce0a11c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/editor_lite.vue @@ -0,0 +1,99 @@ +<script> +import { debounce } from 'lodash'; +import Editor from '~/editor/editor_lite'; +import { CONTENT_UPDATE_DEBOUNCE } from '~/editor/constants'; + +function initEditorLite({ el, ...args }) { + const editor = new Editor({ + scrollbar: { + alwaysConsumeMouseWheel: false, + }, + }); + + return editor.createInstance({ + el, + ...args, + }); +} + +export default { + inheritAttrs: false, + props: { + value: { + type: String, + required: false, + default: '', + }, + fileName: { + type: String, + required: false, + default: '', + }, + // This is used to help uniquely create a monaco model + // even if two blob's share a file path. + fileGlobalId: { + type: String, + required: false, + default: '', + }, + extensions: { + type: [String, Array], + required: false, + default: () => null, + }, + editorOptions: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + loading: true, + editor: null, + }; + }, + watch: { + fileName(newVal) { + this.editor.updateModelLanguage(newVal); + }, + value(newVal) { + if (this.editor.getValue() !== newVal) { + this.editor.setValue(newVal); + } + }, + }, + mounted() { + this.editor = initEditorLite({ + el: this.$refs.editor, + blobPath: this.fileName, + blobContent: this.value, + blobGlobalId: this.fileGlobalId, + extensions: this.extensions, + ...this.editorOptions, + }); + + this.editor.onDidChangeModelContent( + debounce(this.onFileChange.bind(this), CONTENT_UPDATE_DEBOUNCE), + ); + }, + beforeDestroy() { + this.editor.dispose(); + }, + methods: { + onFileChange() { + this.$emit('input', this.editor.getValue()); + }, + }, +}; +</script> +<template> + <div + :id="`editor-lite-${fileGlobalId}`" + ref="editor" + data-editor-loading + @editor-ready="$emit('editor-ready')" + > + <pre class="editor-loading-content">{{ value }}</pre> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index 012aca8105a..386df617d47 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -230,13 +230,12 @@ export default { @keydown="onKeydown($event)" @keyup="onKeyup($event)" /> - <i - :class="{ - hidden: showClearInputButton, - }" + <gl-icon + name="search" + class="dropdown-input-search" + :class="{ hidden: showClearInputButton }" aria-hidden="true" - class="fa fa-search dropdown-input-search" - ></i> + /> <gl-icon name="close" class="dropdown-input-clear" diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js index b70f093e930..91a0ac3aa92 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js @@ -9,6 +9,12 @@ const fileExtensionIcons = { 'md.rendered': 'markdown', markdown: 'markdown', 'markdown.rendered': 'markdown', + mdown: 'markdown', + 'mdown.rendered': 'markdown', + mkd: 'markdown', + 'mkd.rendered': 'markdown', + mkdn: 'markdown', + 'mkdn.rendered': 'markdown', rst: 'markdown', blink: 'blink', css: 'css', diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js new file mode 100644 index 00000000000..443cb28cf10 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js @@ -0,0 +1,121 @@ +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import Api from '~/api'; +import * as types from './mutation_types'; + +export const setEndpoints = ({ commit }, params) => { + const { milestonesEndpoint, labelsEndpoint, groupEndpoint, projectEndpoint } = params; + commit(types.SET_MILESTONES_ENDPOINT, milestonesEndpoint); + commit(types.SET_LABELS_ENDPOINT, labelsEndpoint); + commit(types.SET_GROUP_ENDPOINT, groupEndpoint); + commit(types.SET_PROJECT_ENDPOINT, projectEndpoint); +}; + +export function fetchBranches({ commit, state }, search = '') { + const { projectEndpoint } = state; + commit(types.REQUEST_BRANCHES); + + return Api.branches(projectEndpoint, search) + .then(response => { + commit(types.RECEIVE_BRANCHES_SUCCESS, response.data); + return response; + }) + .catch(({ response }) => { + const { status } = response; + commit(types.RECEIVE_BRANCHES_ERROR, status); + createFlash(__('Failed to load branches. Please try again.')); + }); +} + +export const fetchMilestones = ({ commit, state }, search_title = '') => { + commit(types.REQUEST_MILESTONES); + const { milestonesEndpoint } = state; + + return axios + .get(milestonesEndpoint, { params: { search_title } }) + .then(response => { + commit(types.RECEIVE_MILESTONES_SUCCESS, response.data); + return response; + }) + .catch(({ response }) => { + const { status } = response; + commit(types.RECEIVE_MILESTONES_ERROR, status); + createFlash(__('Failed to load milestones. Please try again.')); + }); +}; + +export const fetchLabels = ({ commit, state }, search = '') => { + commit(types.REQUEST_LABELS); + + return axios + .get(state.labelsEndpoint, { params: { search } }) + .then(response => { + commit(types.RECEIVE_LABELS_SUCCESS, response.data); + return response; + }) + .catch(({ response }) => { + const { status } = response; + commit(types.RECEIVE_LABELS_ERROR, status); + createFlash(__('Failed to load labels. Please try again.')); + }); +}; + +function fetchUser(options = {}) { + const { commit, projectEndpoint, groupEndpoint, query, action, errorMessage } = options; + commit(`REQUEST_${action}`); + + let fetchUserPromise; + if (projectEndpoint) { + fetchUserPromise = Api.projectUsers(projectEndpoint, query).then(data => ({ data })); + } else { + fetchUserPromise = Api.groupMembers(groupEndpoint, { query }); + } + + return fetchUserPromise + .then(response => { + commit(`RECEIVE_${action}_SUCCESS`, response.data); + return response; + }) + .catch(({ response }) => { + const { status } = response; + commit(`RECEIVE_${action}_ERROR`, status); + createFlash(errorMessage); + }); +} + +export const fetchAuthors = ({ commit, state }, query = '') => { + const { projectEndpoint, groupEndpoint } = state; + + return fetchUser({ + commit, + query, + projectEndpoint, + groupEndpoint, + action: 'AUTHORS', + errorMessage: __('Failed to load authors. Please try again.'), + }); +}; + +export const fetchAssignees = ({ commit, state }, query = '') => { + const { projectEndpoint, groupEndpoint } = state; + + return fetchUser({ + commit, + query, + projectEndpoint, + groupEndpoint, + action: 'ASSIGNEES', + errorMessage: __('Failed to load assignees. Please try again.'), + }); +}; + +export const setFilters = ({ commit, dispatch }, filters) => { + commit(types.SET_SELECTED_FILTERS, filters); + + return dispatch('setFilters', filters, { root: true }); +}; + +export const initialize = ({ commit }, initialFilters) => { + commit(types.SET_SELECTED_FILTERS, initialFilters); +}; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/index.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/index.js new file mode 100644 index 00000000000..665bb29a17e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default { + namespaced: true, + actions, + mutations, + state: state(), +}; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types.js new file mode 100644 index 00000000000..07163550524 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types.js @@ -0,0 +1,26 @@ +export const SET_MILESTONES_ENDPOINT = 'SET_MILESTONES_ENDPOINT'; +export const SET_LABELS_ENDPOINT = 'SET_LABELS_ENDPOINT'; +export const SET_GROUP_ENDPOINT = 'SET_GROUP_ENDPOINT'; +export const SET_PROJECT_ENDPOINT = 'SET_PROJECT_ENDPOINT'; + +export const REQUEST_BRANCHES = 'REQUEST_BRANCHES'; +export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS'; +export const RECEIVE_BRANCHES_ERROR = 'RECEIVE_BRANCHES_ERROR'; + +export const REQUEST_MILESTONES = 'REQUEST_MILESTONES'; +export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS'; +export const RECEIVE_MILESTONES_ERROR = 'RECEIVE_MILESTONES_ERROR'; + +export const REQUEST_LABELS = 'REQUEST_LABELS'; +export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS'; +export const RECEIVE_LABELS_ERROR = 'RECEIVE_LABELS_ERROR'; + +export const REQUEST_AUTHORS = 'REQUEST_AUTHORS'; +export const RECEIVE_AUTHORS_SUCCESS = 'RECEIVE_AUTHORS_SUCCESS'; +export const RECEIVE_AUTHORS_ERROR = 'RECEIVE_AUTHORS_ERROR'; + +export const REQUEST_ASSIGNEES = 'REQUEST_ASSIGNEES'; +export const RECEIVE_ASSIGNEES_SUCCESS = 'RECEIVE_ASSIGNEES_SUCCESS'; +export const RECEIVE_ASSIGNEES_ERROR = 'RECEIVE_ASSIGNEES_ERROR'; + +export const SET_SELECTED_FILTERS = 'SET_SELECTED_FILTERS'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutations.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutations.js new file mode 100644 index 00000000000..056b1c6310f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutations.js @@ -0,0 +1,109 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_SELECTED_FILTERS](state, params) { + const { + selectedSourceBranch = null, + selectedSourceBranchList = [], + selectedTargetBranch = null, + selectedTargetBranchList = [], + selectedAuthor = null, + selectedAuthorList = [], + selectedMilestone = null, + selectedMilestoneList = [], + selectedAssignee = null, + selectedAssigneeList = [], + selectedLabel = null, + selectedLabelList = [], + } = params; + state.branches.source.selected = selectedSourceBranch; + state.branches.source.selectedList = selectedSourceBranchList; + state.branches.target.selected = selectedTargetBranch; + state.branches.target.selectedList = selectedTargetBranchList; + state.authors.selected = selectedAuthor; + state.authors.selectedList = selectedAuthorList; + state.assignees.selected = selectedAssignee; + state.assignees.selectedList = selectedAssigneeList; + state.milestones.selected = selectedMilestone; + state.milestones.selectedList = selectedMilestoneList; + state.labels.selected = selectedLabel; + state.labels.selectedList = selectedLabelList; + }, + [types.SET_MILESTONES_ENDPOINT](state, milestonesEndpoint) { + state.milestonesEndpoint = milestonesEndpoint; + }, + [types.SET_LABELS_ENDPOINT](state, labelsEndpoint) { + state.labelsEndpoint = labelsEndpoint; + }, + [types.SET_GROUP_ENDPOINT](state, groupEndpoint) { + state.groupEndpoint = groupEndpoint; + }, + [types.SET_PROJECT_ENDPOINT](state, projectEndpoint) { + state.projectEndpoint = projectEndpoint; + }, + [types.REQUEST_MILESTONES](state) { + state.milestones.isLoading = true; + }, + [types.RECEIVE_MILESTONES_SUCCESS](state, data) { + state.milestones.isLoading = false; + state.milestones.data = data; + state.milestones.errorCode = null; + }, + [types.RECEIVE_MILESTONES_ERROR](state, errorCode) { + state.milestones.isLoading = false; + state.milestones.errorCode = errorCode; + state.milestones.data = []; + }, + [types.REQUEST_LABELS](state) { + state.labels.isLoading = true; + }, + [types.RECEIVE_LABELS_SUCCESS](state, data) { + state.labels.isLoading = false; + state.labels.data = data; + state.labels.errorCode = null; + }, + [types.RECEIVE_LABELS_ERROR](state, errorCode) { + state.labels.isLoading = false; + state.labels.errorCode = errorCode; + state.labels.data = []; + }, + [types.REQUEST_AUTHORS](state) { + state.authors.isLoading = true; + }, + [types.RECEIVE_AUTHORS_SUCCESS](state, data) { + state.authors.isLoading = false; + state.authors.data = data; + state.authors.errorCode = null; + }, + [types.RECEIVE_AUTHORS_ERROR](state, errorCode) { + state.authors.isLoading = false; + state.authors.errorCode = errorCode; + state.authors.data = []; + }, + [types.REQUEST_ASSIGNEES](state) { + state.assignees.isLoading = true; + }, + [types.RECEIVE_ASSIGNEES_SUCCESS](state, data) { + state.assignees.isLoading = false; + state.assignees.data = data; + state.assignees.errorCode = null; + }, + [types.RECEIVE_ASSIGNEES_ERROR](state, errorCode) { + state.assignees.isLoading = false; + state.assignees.errorCode = errorCode; + state.assignees.data = []; + }, + [types.REQUEST_BRANCHES](state) { + state.branches.isLoading = true; + }, + [types.RECEIVE_BRANCHES_SUCCESS](state, data) { + state.branches.isLoading = false; + state.branches.data = data; + state.branches.errorCode = null; + }, + [types.RECEIVE_BRANCHES_ERROR](state, errorCode) { + state.branches.isLoading = false; + state.branches.errorCode = errorCode; + state.branches.data = []; + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/state.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/state.js new file mode 100644 index 00000000000..f89f5efc341 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/state.js @@ -0,0 +1,47 @@ +export default () => ({ + milestonesEndpoint: '', + labelsEndpoint: '', + groupEndpoint: '', + projectEndpoint: '', + branches: { + isLoading: false, + errorCode: null, + data: [], + source: { + selected: null, + selectedList: [], + }, + target: { + selected: null, + selectedList: [], + }, + }, + milestones: { + isLoading: false, + errorCode: null, + data: [], + selected: null, + selectedList: [], + }, + labels: { + isLoading: false, + errorCode: null, + data: [], + selected: null, + selectedList: [], + }, + authors: { + isLoading: false, + errorCode: null, + data: [], + selected: null, + selectedList: [], + }, + assignees: { + isLoading: false, + errorCode: null, + data: [], + selected: null, + selectedList: [], + }, +}); diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue index da4b0aedef5..e895a7a52ab 100644 --- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue +++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue @@ -1,5 +1,5 @@ <script> -import { escape } from 'lodash'; +import { escape, last } from 'lodash'; import Tribute from 'tributejs'; import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from '~/lib/utils/common_utils'; @@ -12,6 +12,8 @@ const AutoComplete = { MergeRequests: 'mergeRequests', }; +const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings + function doesCurrentLineStartWith(searchString, fullText, selectionStart) { const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length; const currentLine = fullText.split('\n')[currentLineNumber - 1]; @@ -74,30 +76,40 @@ const autoCompleteMap = { return this.members; }, menuItemTemplate({ original }) { - const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : ''; - - const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass} - gl-display-inline-flex! gl-align-items-center gl-justify-content-center`; - - const avatarTag = original.avatar_url - ? `<img - src="${original.avatar_url}" - alt="${original.username}'s avatar" - class="${avatarClasses}"/>` - : `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`; - - const name = escape(original.name); + const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0'; + const noAvatarClasses = `${commonClasses} gl-rounded-small + gl-display-flex gl-align-items-center gl-justify-content-center`; + + const avatar = original.avatar_url + ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />` + : `<div class="${noAvatarClasses}" aria-hidden="true"> + ${original.username.charAt(0).toUpperCase()}</div>`; + + let displayName = original.name; + let parentGroupOrUsername = `@${original.username}`; + + if (original.type === groupType) { + const splitName = original.name.split(' / '); + displayName = splitName.pop(); + parentGroupOrUsername = splitName.pop(); + } const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : ''; - const icon = original.mentionsDisabled - ? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3') + const disabledMentionsIcon = original.mentionsDisabled + ? spriteIcon('notifications-off', 's16 gl-ml-3') : ''; - return `${avatarTag} - ${original.username} - <small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small> - ${icon}`; + return ` + <div class="gl-display-flex gl-align-items-center"> + ${avatar} + <div class="gl-font-sm gl-line-height-normal gl-ml-3"> + <div>${escape(displayName)}${count}</div> + <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div> + </div> + ${disabledMentionsIcon} + </div> + `; }, }, [AutoComplete.MergeRequests]: { @@ -134,7 +146,8 @@ export default { { trigger: '@', fillAttr: 'username', - lookup: value => value.name + value.username, + lookup: value => + value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username, menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate, values: this.getValues(AutoComplete.Members), }, diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 6ff6f10f786..79d9ba6df57 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -1,10 +1,11 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlTooltipDirective, GlLink, GlDeprecatedButton } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { GlTooltipDirective, GlLink, GlButton, GlTooltip } from '@gitlab/ui'; import CiIconBadge from './ci_badge_link.vue'; import TimeagoTooltip from './time_ago_tooltip.vue'; import UserAvatarImage from './user_avatar/user_avatar_image.vue'; +import { glEmojiTag } from '../../emoji'; +import { __, sprintf } from '../../locale'; /** * Renders header component for job and pipeline page based on UI mockups @@ -19,11 +20,13 @@ export default { TimeagoTooltip, UserAvatarImage, GlLink, - GlDeprecatedButton, + GlButton, + GlTooltip, }, directives: { GlTooltip: GlTooltipDirective, }, + EMOJI_REF: 'EMOJI_REF', props: { status: { type: Object, @@ -62,6 +65,27 @@ export default { userAvatarAltText() { return sprintf(__(`%{username}'s avatar`), { username: this.user.name }); }, + userPath() { + // GraphQL returns `webPath` and Rest `path` + return this.user?.webPath || this.user?.path; + }, + avatarUrl() { + // GraphQL returns `avatarUrl` and Rest `avatar_url` + return this.user?.avatarUrl || this.user?.avatar_url; + }, + statusTooltipHTML() { + // Rest `status_tooltip_html` which is a ready to work + // html for the emoji and the status text inside a tooltip. + // GraphQL returns `status.emoji` and `status.message` which + // needs to be combined to make the html we want. + const { emoji } = this.user?.status || {}; + const emojiHtml = emoji ? glEmojiTag(emoji) : ''; + + return emojiHtml || this.user?.status_tooltip_html; + }, + message() { + return this.user?.status?.message; + }, }, methods: { @@ -73,7 +97,11 @@ export default { </script> <template> - <header class="page-content-header ci-header-container"> + <header + class="page-content-header gl-display-flex gl-min-h-7" + data-qa-selector="pipeline_header" + data-testid="ci-header-content" + > <section class="header-main-content"> <ci-icon-badge :status="status" /> @@ -89,12 +117,12 @@ export default { <template v-if="user"> <gl-link v-gl-tooltip - :href="user.path" + :href="userPath" :title="user.email" class="js-user-link commit-committer-link" > <user-avatar-image - :img-src="user.avatar_url" + :img-src="avatarUrl" :img-alt="userAvatarAltText" :tooltip-text="user.name" :img-size="24" @@ -102,21 +130,27 @@ export default { {{ user.name }} </gl-link> - <span v-if="user.status_tooltip_html" v-html="user.status_tooltip_html"></span> + <gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]"> + {{ message }} + </gl-tooltip> + <span + v-if="statusTooltipHTML" + :ref="$options.EMOJI_REF" + :data-testid="message" + v-html="statusTooltipHTML" + ></span> </template> </section> <section v-if="$slots.default" data-testid="headerButtons" class="gl-display-flex"> <slot></slot> </section> - <gl-deprecated-button + <gl-button v-if="hasSidebarButton" - id="toggleSidebar" - class="d-block d-sm-none -sidebar-toggle-btn js-sidebar-build-toggle js-sidebar-build-toggle-header" + class="d-sm-none js-sidebar-build-toggle gl-ml-auto" + icon="angle-double-left" + :aria-label="__('Toggle sidebar')" @click="onClickSidebarButton" - > - <i class="fa fa-angle-double-left" aria-hidden="true" aria-labelledby="toggleSidebar"> </i> - </gl-deprecated-button> + /> </header> </template> diff --git a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue index b5d6b872547..80c03342f11 100644 --- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue +++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue @@ -1,4 +1,6 @@ <script> +import { isEqual } from 'lodash'; + export default { props: { storageKey: { @@ -6,31 +8,65 @@ export default { required: true, }, value: { - type: String, + type: [String, Number, Boolean, Array, Object], required: false, default: '', }, + asJson: { + type: Boolean, + required: false, + default: false, + }, + persist: { + type: Boolean, + required: false, + default: true, + }, }, watch: { value(newVal) { - this.saveValue(newVal); + this.saveValue(this.serialize(newVal)); }, }, mounted() { // On mount, trigger update if we actually have a localStorageValue - const value = this.getValue(); + const { exists, value } = this.getStorageValue(); - if (value && this.value !== value) { + if (exists && !isEqual(value, this.value)) { this.$emit('input', value); } }, methods: { - getValue() { - return localStorage.getItem(this.storageKey); + getStorageValue() { + const value = localStorage.getItem(this.storageKey); + + if (value === null) { + return { exists: false }; + } + + try { + return { exists: true, value: this.deserialize(value) }; + } catch { + // eslint-disable-next-line no-console + console.warn( + `[gitlab] Failed to deserialize value from localStorage (key=${this.storageKey})`, + value, + ); + // default to "don't use localStorage value" + return { exists: false }; + } }, saveValue(val) { + if (!this.persist) return; + localStorage.setItem(this.storageKey, val); }, + serialize(val) { + return this.asJson ? JSON.stringify(val) : val; + }, + deserialize(val) { + return this.asJson ? JSON.parse(val) : val; + }, }, render() { return this.$slots.default; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index a48c279d0e3..65116ed8ca3 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -25,6 +25,18 @@ export default { }, mixins: [glFeatureFlagsMixin()], props: { + /** + * This prop should be bound to the value of the `<textarea>` element + * that is rendered as a child of this component (in the `textarea` slot) + */ + textareaValue: { + type: String, + required: true, + }, + markdownDocsPath: { + type: String, + required: true, + }, isSubmitting: { type: Boolean, required: false, @@ -35,10 +47,6 @@ export default { required: false, default: '', }, - markdownDocsPath: { - type: String, - required: true, - }, addSpacingClasses: { type: Boolean, required: false, @@ -84,12 +92,6 @@ export default { required: false, default: false, }, - // This prop is used as a fallback in case if textarea.elm is undefined - textareaValue: { - type: String, - required: false, - default: '', - }, }, data() { return { @@ -165,17 +167,20 @@ export default { }, mounted() { // GLForm class handles all the toolbar buttons - return new GLForm($(this.$refs['gl-form']), { - emojis: this.enableAutocomplete, - members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - epics: this.enableAutocomplete, - milestones: this.enableAutocomplete, - labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - snippets: this.enableAutocomplete, - vulnerabilities: this.enableAutocomplete, - }); + return new GLForm( + $(this.$refs['gl-form']), + { + emojis: this.enableAutocomplete, + members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, + issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, + mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, + epics: this.enableAutocomplete, + milestones: this.enableAutocomplete, + labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, + snippets: this.enableAutocomplete, + }, + true, + ); }, beforeDestroy() { const glForm = $(this.$refs['gl-form']).data('glForm'); @@ -189,17 +194,11 @@ export default { this.previewMarkdown = true; - /* - Can't use `$refs` as the component is technically in the parent component - so we access the VNode & then get the element - */ - const text = this.$slots.textarea[0]?.elm?.value || this.textareaValue; - - if (text) { + if (this.textareaValue) { this.markdownPreviewLoading = true; this.markdownPreview = __('Loading…'); axios - .post(this.markdownPreviewPath, { text }) + .post(this.markdownPreviewPath, { text: this.textareaValue }) .then(response => this.renderMarkdown(response.data)) .catch(() => new Flash(__('Error loading markdown preview'))); } else { @@ -234,7 +233,7 @@ export default { <div ref="gl-form" :class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }" - class="js-vue-markdown-field md-area position-relative" + class="js-vue-markdown-field md-area position-relative gfm-form" > <markdown-header :preview-markdown="previewMarkdown" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index 13c42d35b04..13ec7a6ada9 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -27,6 +27,11 @@ export default { type: String, required: true, }, + suggestionsCount: { + type: Number, + required: false, + default: 0, + }, }, computed: { batchSuggestionsCount() { @@ -62,6 +67,7 @@ export default { <div class="md-suggestion"> <suggestion-diff-header class="qa-suggestion-diff-header js-suggestion-diff-header" + :suggestions-count="suggestionsCount" :can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled" :is-applied="suggestion.applied" :is-batched="isBatched" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 1fc54d2f52e..fb9636ba734 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -42,6 +42,11 @@ export default { required: false, default: null, }, + suggestionsCount: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -127,7 +132,7 @@ export default { </div> <div v-else class="d-flex align-items-center"> <gl-button - v-if="canBeBatched && !isDisableButton" + v-if="suggestionsCount > 1 && canBeBatched && !isDisableButton" class="btn-inverted js-add-to-batch-btn btn-grouped" :disabled="isDisableButton" @click="addSuggestionToBatch" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 083f581af05..927a93487e6 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -38,6 +38,11 @@ export default { type: String, required: true, }, + suggestionsCount: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -77,12 +82,12 @@ export default { this.isRendered = true; }, generateDiff(suggestionIndex) { - const { suggestions, disabled, batchSuggestionsInfo, helpPagePath } = this; + const { suggestions, disabled, batchSuggestionsInfo, helpPagePath, suggestionsCount } = this; const suggestion = suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {}; const SuggestionDiffComponent = Vue.extend(SuggestionDiff); const suggestionDiff = new SuggestionDiffComponent({ - propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath }, + propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath, suggestionsCount }, }); suggestionDiff.$on('apply', ({ suggestionId, callback }) => { diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue new file mode 100644 index 00000000000..10078d5cd64 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue @@ -0,0 +1,59 @@ +<script> +import ActionButtonGroup from './action_button_group.vue'; +import RemoveMemberButton from './remove_member_button.vue'; +import ApproveAccessRequestButton from './approve_access_request_button.vue'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'AccessRequestActionButtons', + components: { ActionButtonGroup, RemoveMemberButton, ApproveAccessRequestButton }, + props: { + member: { + type: Object, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + }, + computed: { + message() { + const { user, source } = this.member; + + if (this.isCurrentUser) { + return sprintf( + s__('Members|Are you sure you want to withdraw your access request for "%{source}"'), + { source: source.name }, + ); + } + + return sprintf( + s__('Members|Are you sure you want to deny %{usersName}\'s request to join "%{source}"'), + { usersName: user.name, source: source.name }, + ); + }, + }, +}; +</script> + +<template> + <action-button-group> + <div v-if="permissions.canUpdate" class="gl-px-1"> + <approve-access-request-button :member-id="member.id" /> + </div> + <div v-if="permissions.canRemove" class="gl-px-1"> + <remove-member-button + :member-id="member.id" + :message="message" + :title="s__('Member|Deny access')" + :is-access-request="true" + icon="close" + /> + </div> + </action-button-group> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue new file mode 100644 index 00000000000..8356fdb60b1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue @@ -0,0 +1,11 @@ +<script> +export default { + name: 'ActionButtonGroup', +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-align-items-center gl-justify-content-end gl-mx-n1"> + <slot></slot> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue new file mode 100644 index 00000000000..e8a53ff173d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue @@ -0,0 +1,42 @@ +<script> +import { mapState } from 'vuex'; +import { GlButton, GlForm, GlTooltipDirective } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { __ } from '~/locale'; + +export default { + name: 'ApproveAccessRequestButton', + csrf, + title: __('Grant access'), + components: { GlButton, GlForm }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + memberId: { + type: Number, + required: true, + }, + }, + computed: { + ...mapState(['memberPath']), + approvePath() { + return this.memberPath.replace(/:id$/, `${this.memberId}/approve_access_request`); + }, + }, +}; +</script> + +<template> + <gl-form :action="approvePath" method="post"> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <gl-button + v-gl-tooltip.hover + :title="$options.title" + :aria-label="$options.title" + icon="check" + variant="success" + type="submit" + /> + </gl-form> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue new file mode 100644 index 00000000000..2aebfe80db5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue @@ -0,0 +1,27 @@ +<script> +import ActionButtonGroup from './action_button_group.vue'; +import RemoveGroupLinkButton from './remove_group_link_button.vue'; + +export default { + name: 'GroupActionButtons', + components: { ActionButtonGroup, RemoveGroupLinkButton }, + props: { + member: { + type: Object, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <action-button-group> + <div v-if="permissions.canRemove" class="gl-px-1"> + <remove-group-link-button :group-link="member" /> + </div> + </action-button-group> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue new file mode 100644 index 00000000000..2b0a75640e2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue @@ -0,0 +1,48 @@ +<script> +import ActionButtonGroup from './action_button_group.vue'; +import RemoveMemberButton from './remove_member_button.vue'; +import ResendInviteButton from './resend_invite_button.vue'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'InviteActionButtons', + components: { ActionButtonGroup, RemoveMemberButton, ResendInviteButton }, + props: { + member: { + type: Object, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + }, + computed: { + message() { + const { invite, source } = this.member; + + return sprintf( + s__( + 'Members|Are you sure you want to revoke the invitation for %{inviteEmail} to join "%{source}"', + ), + { inviteEmail: invite.email, source: source.name }, + ); + }, + }, +}; +</script> + +<template> + <action-button-group> + <div v-if="permissions.canResend" class="gl-px-1"> + <resend-invite-button :member-id="member.id" /> + </div> + <div v-if="permissions.canRemove" class="gl-px-1"> + <remove-member-button + :member-id="member.id" + :message="message" + :title="s__('Member|Revoke invite')" + /> + </div> + </action-button-group> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue new file mode 100644 index 00000000000..d9976e7181c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue @@ -0,0 +1,40 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import LeaveModal from '../modals/leave_modal.vue'; +import { LEAVE_MODAL_ID } from '../constants'; + +export default { + name: 'LeaveButton', + title: __('Leave'), + modalId: LEAVE_MODAL_ID, + components: { + GlButton, + LeaveModal, + }, + directives: { + GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, + }, + props: { + member: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <gl-button + v-gl-tooltip.hover + v-gl-modal="$options.modalId" + :title="$options.title" + :aria-label="$options.title" + icon="leave" + variant="danger" + /> + <leave-modal :member="member" /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue new file mode 100644 index 00000000000..9d89cb40676 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue @@ -0,0 +1,36 @@ +<script> +import { mapActions } from 'vuex'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'RemoveGroupLinkButton', + i18n: { + buttonTitle: s__('Members|Remove group'), + }, + components: { GlButton }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + groupLink: { + type: Object, + required: true, + }, + }, + methods: { + ...mapActions(['showRemoveGroupLinkModal']), + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip.hover + variant="danger" + :title="$options.i18n.buttonTitle" + :aria-label="$options.i18n.buttonTitle" + icon="remove" + @click="showRemoveGroupLinkModal(groupLink)" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue new file mode 100644 index 00000000000..b0b7ff4ce9a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue @@ -0,0 +1,57 @@ +<script> +import { mapState } from 'vuex'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; + +export default { + name: 'RemoveMemberButton', + components: { GlButton }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + memberId: { + type: Number, + required: true, + }, + message: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + icon: { + type: String, + required: false, + default: 'remove', + }, + isAccessRequest: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapState(['memberPath']), + computedMemberPath() { + return this.memberPath.replace(':id', this.memberId); + }, + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip.hover + class="js-remove-member-button" + variant="danger" + :title="title" + :aria-label="title" + :icon="icon" + :data-member-path="computedMemberPath" + :data-is-access-request="isAccessRequest" + :data-message="message" + data-qa-selector="delete_member_button" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue new file mode 100644 index 00000000000..1cc3fd17e98 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue @@ -0,0 +1,41 @@ +<script> +import { mapState } from 'vuex'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { __ } from '~/locale'; + +export default { + name: 'ResendInviteButton', + csrf, + title: __('Resend invite'), + components: { GlButton }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + memberId: { + type: Number, + required: true, + }, + }, + computed: { + ...mapState(['memberPath']), + resendPath() { + return this.memberPath.replace(/:id$/, `${this.memberId}/resend_invite`); + }, + }, +}; +</script> + +<template> + <form :action="resendPath" method="post"> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <gl-button + v-gl-tooltip.hover + :title="$options.title" + :aria-label="$options.title" + icon="paper-airplane" + type="submit" + /> + </form> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue new file mode 100644 index 00000000000..8fa3d439fc1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue @@ -0,0 +1,61 @@ +<script> +import ActionButtonGroup from './action_button_group.vue'; +import RemoveMemberButton from './remove_member_button.vue'; +import LeaveButton from './leave_button.vue'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'UserActionButtons', + components: { ActionButtonGroup, RemoveMemberButton, LeaveButton }, + props: { + member: { + type: Object, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + }, + computed: { + message() { + const { user, source } = this.member; + + if (user) { + return sprintf( + s__('Members|Are you sure you want to remove %{usersName} from "%{source}"'), + { + usersName: user.name, + source: source.name, + }, + ); + } + + return sprintf( + s__('Members|Are you sure you want to remove this orphaned member from "%{source}"'), + { + source: source.name, + }, + ); + }, + }, +}; +</script> + +<template> + <action-button-group> + <div v-if="permissions.canRemove" class="gl-px-1"> + <leave-button v-if="isCurrentUser" :member="member" /> + <remove-member-button + v-else + :member-id="member.id" + :message="message" + :title="s__('Member|Remove member')" + /> + </div> + </action-button-group> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue new file mode 100644 index 00000000000..12b748f9ab6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue @@ -0,0 +1,34 @@ +<script> +import { GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui'; +import { AVATAR_SIZE } from '../constants'; + +export default { + name: 'GroupAvatar', + avatarSize: AVATAR_SIZE, + components: { GlAvatarLink, GlAvatarLabeled }, + props: { + member: { + type: Object, + required: true, + }, + }, + computed: { + group() { + return this.member.sharedWithGroup; + }, + }, +}; +</script> + +<template> + <gl-avatar-link :href="group.webUrl"> + <gl-avatar-labeled + :label="group.fullName" + :src="group.avatarUrl" + :alt="group.fullName" + :size="$options.avatarSize" + :entity-name="group.name" + :entity-id="group.id" + /> + </gl-avatar-link> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue new file mode 100644 index 00000000000..28654a60860 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue @@ -0,0 +1,32 @@ +<script> +import { GlAvatarLabeled } from '@gitlab/ui'; +import { AVATAR_SIZE } from '../constants'; + +export default { + name: 'InviteAvatar', + avatarSize: AVATAR_SIZE, + components: { GlAvatarLabeled }, + props: { + member: { + type: Object, + required: true, + }, + }, + computed: { + invite() { + return this.member.invite; + }, + }, +}; +</script> + +<template> + <gl-avatar-labeled + :label="invite.email" + :src="invite.avatarUrl" + :alt="invite.email" + :size="$options.avatarSize" + :entity-name="invite.email" + :entity-id="member.id" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue new file mode 100644 index 00000000000..e5e7cdf149c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue @@ -0,0 +1,91 @@ +<script> +import { + GlAvatarLink, + GlAvatarLabeled, + GlBadge, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; +import { generateBadges } from 'ee_else_ce/vue_shared/components/members/utils'; +import { __ } from '~/locale'; +import { AVATAR_SIZE } from '../constants'; +import { glEmojiTag } from '~/emoji'; + +export default { + name: 'UserAvatar', + avatarSize: AVATAR_SIZE, + orphanedUserLabel: __('Orphaned member'), + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, + components: { + GlAvatarLink, + GlAvatarLabeled, + GlBadge, + }, + directives: { + SafeHtml, + }, + props: { + member: { + type: Object, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + }, + computed: { + user() { + return this.member.user; + }, + badges() { + return generateBadges(this.member, this.isCurrentUser).filter(badge => badge.show); + }, + statusEmoji() { + return this.user?.status?.emoji; + }, + }, + methods: { + glEmojiTag, + }, +}; +</script> + +<template> + <gl-avatar-link + v-if="user" + class="js-user-link" + :href="user.webUrl" + :data-user-id="user.id" + :data-username="user.username" + > + <gl-avatar-labeled + :label="user.name" + :sub-label="`@${user.username}`" + :src="user.avatarUrl" + :alt="user.name" + :size="$options.avatarSize" + :entity-name="user.name" + :entity-id="user.id" + > + <template #meta> + <div v-if="statusEmoji" class="gl-p-1"> + <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag(statusEmoji)"></span> + </div> + <div v-for="badge in badges" :key="badge.text" class="gl-p-1"> + <gl-badge size="sm" :variant="badge.variant"> + {{ badge.text }} + </gl-badge> + </div> + </template> + </gl-avatar-labeled> + </gl-avatar-link> + + <gl-avatar-labeled + v-else + :label="$options.orphanedUserLabel" + :alt="$options.orphanedUserLabel" + :size="$options.avatarSize" + :entity-name="$options.orphanedUserLabel" + :entity-id="member.id" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/constants.js b/app/assets/javascripts/vue_shared/components/members/constants.js new file mode 100644 index 00000000000..6509779053e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/constants.js @@ -0,0 +1,70 @@ +import { __ } from '~/locale'; + +export const FIELDS = [ + { + key: 'account', + label: __('Account'), + }, + { + key: 'source', + label: __('Source'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'granted', + label: __('Access granted'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'invited', + label: __('Invited'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'requested', + label: __('Requested'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'expires', + label: __('Access expires'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'maxRole', + label: __('Max role'), + thClass: 'col-max-role', + tdClass: 'col-max-role', + }, + { + key: 'expiration', + label: __('Expiration'), + thClass: 'col-expiration', + tdClass: 'col-expiration', + }, + { + key: 'actions', + thClass: 'col-actions', + tdClass: 'col-actions', + }, +]; + +export const AVATAR_SIZE = 48; + +export const MEMBER_TYPES = { + user: 'user', + group: 'group', + invite: 'invite', + accessRequest: 'accessRequest', +}; + +export const DAYS_TO_EXPIRE_SOON = 7; + +export const LEAVE_MODAL_ID = 'member-leave-modal'; + +export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id'; diff --git a/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue b/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue new file mode 100644 index 00000000000..9a2ce0d4931 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue @@ -0,0 +1,70 @@ +<script> +import { mapState } from 'vuex'; +import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { __, s__, sprintf } from '~/locale'; +import { LEAVE_MODAL_ID } from '../constants'; + +export default { + name: 'LeaveModal', + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: __('Leave'), + attributes: { + variant: 'danger', + }, + }, + csrf, + modalId: LEAVE_MODAL_ID, + modalContent: s__('Members|Are you sure you want to leave "%{source}"?'), + components: { GlModal, GlForm, GlSprintf }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + member: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['memberPath']), + leavePath() { + return this.memberPath.replace(/:id$/, 'leave'); + }, + modalTitle() { + return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.name }); + }, + }, + methods: { + handlePrimary() { + this.$refs.form.$el.submit(); + }, + }, +}; +</script> + +<template> + <gl-modal + v-bind="$attrs" + :modal-id="$options.modalId" + :title="modalTitle" + :action-primary="$options.actionPrimary" + :action-cancel="$options.actionCancel" + size="sm" + @primary="handlePrimary" + > + <gl-form ref="form" :action="leavePath" method="post"> + <p> + <gl-sprintf :message="$options.modalContent"> + <template #source>{{ member.source.name }}</template> + </gl-sprintf> + </p> + + <input type="hidden" name="_method" value="delete" /> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + </gl-form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue b/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue new file mode 100644 index 00000000000..e8890717724 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue @@ -0,0 +1,69 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlModal, GlSprintf, GlForm } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { __, s__, sprintf } from '~/locale'; +import { REMOVE_GROUP_LINK_MODAL_ID } from '../constants'; + +export default { + name: 'RemoveGroupLinkModal', + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: s__('Members|Remove group'), + attributes: { + variant: 'danger', + }, + }, + csrf, + i18n: { + modalBody: s__('Members|Are you sure you want to remove "%{groupName}"?'), + }, + modalId: REMOVE_GROUP_LINK_MODAL_ID, + components: { GlModal, GlSprintf, GlForm }, + computed: { + ...mapState(['memberPath', 'groupLinkToRemove', 'removeGroupLinkModalVisible']), + groupLinkPath() { + return this.memberPath.replace(/:id$/, this.groupLinkToRemove?.id); + }, + groupName() { + return this.groupLinkToRemove?.sharedWithGroup.fullName; + }, + modalTitle() { + return sprintf(s__('Members|Remove "%{groupName}"'), { groupName: this.groupName }); + }, + }, + methods: { + ...mapActions(['hideRemoveGroupLinkModal']), + handlePrimary() { + this.$refs.form.$el.submit(); + }, + }, +}; +</script> + +<template> + <gl-modal + v-bind="$attrs" + :modal-id="$options.modalId" + :visible="removeGroupLinkModalVisible" + :title="modalTitle" + :action-primary="$options.actionPrimary" + :action-cancel="$options.actionCancel" + size="sm" + @primary="handlePrimary" + @hide="hideRemoveGroupLinkModal" + > + <gl-form ref="form" :action="groupLinkPath" method="post"> + <p> + <gl-sprintf :message="$options.i18n.modalBody"> + <template #groupName>{{ groupName }}</template> + </gl-sprintf> + </p> + + <input type="hidden" name="_method" value="delete" /> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + </gl-form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/created_at.vue b/app/assets/javascripts/vue_shared/components/members/table/created_at.vue new file mode 100644 index 00000000000..0bad70894f9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/created_at.vue @@ -0,0 +1,40 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + name: 'CreatedAt', + components: { GlSprintf, TimeAgoTooltip }, + props: { + date: { + type: String, + required: false, + default: null, + }, + createdBy: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + showCreatedBy() { + return this.createdBy?.name && this.createdBy?.webUrl; + }, + }, +}; +</script> + +<template> + <span> + <gl-sprintf v-if="showCreatedBy" :message="s__('Members|%{time} by %{user}')"> + <template #time> + <time-ago-tooltip :time="date" /> + </template> + <template #user> + <a :href="createdBy.webUrl">{{ createdBy.name }}</a> + </template> + </gl-sprintf> + <time-ago-tooltip v-else :time="date" /> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue b/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue new file mode 100644 index 00000000000..de65e3fb10f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue @@ -0,0 +1,66 @@ +<script> +import { GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { + approximateDuration, + differenceInSeconds, + formatDate, + getDayDifference, +} from '~/lib/utils/datetime_utility'; +import { DAYS_TO_EXPIRE_SOON } from '../constants'; + +export default { + name: 'ExpiresAt', + components: { GlSprintf }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + date: { + type: String, + required: false, + default: null, + }, + }, + computed: { + noExpirationSet() { + return this.date === null; + }, + parsed() { + return new Date(this.date); + }, + differenceInSeconds() { + return differenceInSeconds(new Date(), this.parsed); + }, + isExpired() { + return this.differenceInSeconds <= 0; + }, + inWords() { + return approximateDuration(this.differenceInSeconds); + }, + formatted() { + return formatDate(this.parsed); + }, + expiresSoon() { + return getDayDifference(new Date(), this.parsed) < DAYS_TO_EXPIRE_SOON; + }, + cssClass() { + return { + 'gl-text-red-500': this.isExpired, + 'gl-text-orange-500': this.expiresSoon, + }; + }, + }, +}; +</script> + +<template> + <span v-if="noExpirationSet">{{ s__('Members|No expiration set') }}</span> + <span v-else v-gl-tooltip.hover :title="formatted" :class="cssClass"> + <template v-if="isExpired">{{ s__('Members|Expired') }}</template> + <gl-sprintf v-else :message="s__('Members|in %{time}')"> + <template #time> + {{ inWords }} + </template> + </gl-sprintf> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue new file mode 100644 index 00000000000..320d8c99223 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue @@ -0,0 +1,57 @@ +<script> +import UserActionButtons from '../action_buttons/user_action_buttons.vue'; +import GroupActionButtons from '../action_buttons/group_action_buttons.vue'; +import InviteActionButtons from '../action_buttons/invite_action_buttons.vue'; +import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue'; +import { MEMBER_TYPES } from '../constants'; + +export default { + name: 'MemberActionButtons', + components: { + UserActionButtons, + GroupActionButtons, + InviteActionButtons, + AccessRequestActionButtons, + }, + props: { + member: { + type: Object, + required: true, + }, + memberType: { + type: String, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + }, + computed: { + actionButtonComponent() { + const dictionary = { + [MEMBER_TYPES.user]: 'user-action-buttons', + [MEMBER_TYPES.group]: 'group-action-buttons', + [MEMBER_TYPES.invite]: 'invite-action-buttons', + [MEMBER_TYPES.accessRequest]: 'access-request-action-buttons', + }; + + return dictionary[this.memberType]; + }, + }, +}; +</script> + +<template> + <component + :is="actionButtonComponent" + v-if="actionButtonComponent" + :member="member" + :permissions="permissions" + :is-current-user="isCurrentUser" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue b/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue new file mode 100644 index 00000000000..a1f98d4008a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue @@ -0,0 +1,35 @@ +<script> +import { kebabCase } from 'lodash'; +import UserAvatar from '../avatars/user_avatar.vue'; +import InviteAvatar from '../avatars/invite_avatar.vue'; +import GroupAvatar from '../avatars/group_avatar.vue'; + +export default { + name: 'MemberAvatar', + components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar }, + props: { + memberType: { + type: String, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + member: { + type: Object, + required: true, + }, + }, + computed: { + avatarComponent() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${kebabCase(this.memberType)}-avatar`; + }, + }, +}; +</script> + +<template> + <component :is="avatarComponent" :member="member" :is-current-user="isCurrentUser" /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_source.vue b/app/assets/javascripts/vue_shared/components/members/table/member_source.vue new file mode 100644 index 00000000000..030d72c3420 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/member_source.vue @@ -0,0 +1,27 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; + +export default { + name: 'MemberSource', + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + memberSource: { + type: Object, + required: true, + }, + isDirectMember: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <span v-if="isDirectMember">{{ __('Direct member') }}</span> + <a v-else v-gl-tooltip.hover :title="__('Inherited')" :href="memberSource.webUrl">{{ + memberSource.name + }}</a> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue new file mode 100644 index 00000000000..c1a80a85dbe --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue @@ -0,0 +1,110 @@ +<script> +import { mapState } from 'vuex'; +import { GlTable, GlBadge } from '@gitlab/ui'; +import { FIELDS } from '../constants'; +import initUserPopovers from '~/user_popovers'; +import MemberAvatar from './member_avatar.vue'; +import MemberSource from './member_source.vue'; +import CreatedAt from './created_at.vue'; +import ExpiresAt from './expires_at.vue'; +import MemberActionButtons from './member_action_buttons.vue'; +import MembersTableCell from './members_table_cell.vue'; +import RoleDropdown from './role_dropdown.vue'; +import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue'; + +export default { + name: 'MembersTable', + components: { + GlTable, + GlBadge, + MemberAvatar, + CreatedAt, + ExpiresAt, + MembersTableCell, + MemberSource, + MemberActionButtons, + RoleDropdown, + RemoveGroupLinkModal, + }, + computed: { + ...mapState(['members', 'tableFields']), + filteredFields() { + return FIELDS.filter(field => this.tableFields.includes(field.key)); + }, + }, + mounted() { + initUserPopovers(this.$el.querySelectorAll('.js-user-link')); + }, +}; +</script> + +<template> + <div> + <gl-table + class="members-table" + head-variant="white" + stacked="lg" + :fields="filteredFields" + :items="members" + primary-key="id" + thead-class="border-bottom" + :empty-text="__('No members found')" + show-empty + > + <template #cell(account)="{ item: member }"> + <members-table-cell #default="{ memberType, isCurrentUser }" :member="member"> + <member-avatar + :member-type="memberType" + :is-current-user="isCurrentUser" + :member="member" + /> + </members-table-cell> + </template> + + <template #cell(source)="{ item: member }"> + <members-table-cell #default="{ isDirectMember }" :member="member"> + <member-source :is-direct-member="isDirectMember" :member-source="member.source" /> + </members-table-cell> + </template> + + <template #cell(granted)="{ item: { createdAt, createdBy } }"> + <created-at :date="createdAt" :created-by="createdBy" /> + </template> + + <template #cell(invited)="{ item: { createdAt, createdBy } }"> + <created-at :date="createdAt" :created-by="createdBy" /> + </template> + + <template #cell(requested)="{ item: { createdAt } }"> + <created-at :date="createdAt" /> + </template> + + <template #cell(expires)="{ item: { expiresAt } }"> + <expires-at :date="expiresAt" /> + </template> + + <template #cell(maxRole)="{ item: member }"> + <members-table-cell #default="{ permissions }" :member="member"> + <role-dropdown v-if="permissions.canUpdate" :member="member" /> + <gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge> + </members-table-cell> + </template> + + <template #cell(actions)="{ item: member }"> + <members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member"> + <member-action-buttons + :member-type="memberType" + :is-current-user="isCurrentUser" + :permissions="permissions" + :member="member" + /> + </members-table-cell> + </template> + + <template #head(actions)="{ label }"> + <span data-testid="col-actions" class="gl-sr-only">{{ label }}</span> + </template> + </gl-table> + <remove-group-link-modal /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue new file mode 100644 index 00000000000..5602978bb6c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue @@ -0,0 +1,64 @@ +<script> +import { mapState } from 'vuex'; +import { MEMBER_TYPES } from '../constants'; + +export default { + name: 'MembersTableCell', + props: { + member: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['sourceId', 'currentUserId']), + isGroup() { + return Boolean(this.member.sharedWithGroup); + }, + isInvite() { + return Boolean(this.member.invite); + }, + isAccessRequest() { + return Boolean(this.member.requestedAt); + }, + memberType() { + if (this.isGroup) { + return MEMBER_TYPES.group; + } else if (this.isInvite) { + return MEMBER_TYPES.invite; + } else if (this.isAccessRequest) { + return MEMBER_TYPES.accessRequest; + } + + return MEMBER_TYPES.user; + }, + isDirectMember() { + return this.isGroup || this.member.source?.id === this.sourceId; + }, + isCurrentUser() { + return this.member.user?.id === this.currentUserId; + }, + canRemove() { + return this.isDirectMember && this.member.canRemove; + }, + canResend() { + return Boolean(this.member.invite?.canResend); + }, + canUpdate() { + return !this.isCurrentUser && this.isDirectMember && this.member.canUpdate; + }, + }, + render() { + return this.$scopedSlots.default({ + memberType: this.memberType, + isDirectMember: this.isDirectMember, + isCurrentUser: this.isCurrentUser, + permissions: { + canRemove: this.canRemove, + canResend: this.canResend, + canUpdate: this.canUpdate, + }, + }); + }, +}; +</script> diff --git a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue new file mode 100644 index 00000000000..2b40ccc3a9d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue @@ -0,0 +1,70 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import { mapActions } from 'vuex'; +import { s__ } from '~/locale'; + +export default { + name: 'RoleDropdown', + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + member: { + type: Object, + required: true, + }, + }, + data() { + return { + isDesktop: false, + busy: false, + }; + }, + mounted() { + this.isDesktop = bp.isDesktop(); + }, + methods: { + ...mapActions(['updateMemberRole']), + handleSelect(value, name) { + if (value === this.member.accessLevel.integerValue) { + return; + } + + this.busy = true; + + this.updateMemberRole({ + memberId: this.member.id, + accessLevel: { integerValue: value, stringValue: name }, + }) + .then(() => { + this.$toast.show(s__('Members|Role updated successfully.')); + this.busy = false; + }) + .catch(() => { + this.busy = false; + }); + }, + }, +}; +</script> + +<template> + <gl-dropdown + :right="!isDesktop" + :text="member.accessLevel.stringValue" + :header-text="__('Change permissions')" + :disabled="busy" + > + <gl-dropdown-item + v-for="(value, name) in member.validRoles" + :key="value" + is-check-item + :is-checked="value === member.accessLevel.integerValue" + @click="handleSelect(value, name)" + > + {{ name }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/utils.js b/app/assets/javascripts/vue_shared/components/members/utils.js new file mode 100644 index 00000000000..782a0b7f96b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/utils.js @@ -0,0 +1,19 @@ +import { __ } from '~/locale'; + +export const generateBadges = (member, isCurrentUser) => [ + { + show: isCurrentUser, + text: __("It's you"), + variant: 'success', + }, + { + show: member.user?.blocked, + text: __('Blocked'), + variant: 'danger', + }, + { + show: member.user?.twoFactorEnabled, + text: __('2FA'), + variant: 'info', + }, +]; diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue index 35ba7c665d5..cad4439ecea 100644 --- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue @@ -1,19 +1,16 @@ <script> import $ from 'jquery'; -import { GlDeprecatedButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import Clipboard from 'clipboard'; import { __ } from '~/locale'; export default { components: { - GlDeprecatedButton, - GlIcon, + GlButton, }, - directives: { GlTooltip: GlTooltipDirective, }, - props: { text: { type: String, @@ -55,15 +52,12 @@ export default { default: null, }, }, - copySuccessText: __('Copied'), - computed: { modalDomId() { return this.modalId ? `#${this.modalId}` : ''; }, }, - mounted() { this.$nextTick(() => { this.clipboard = new Clipboard(this.$el, { @@ -83,13 +77,11 @@ export default { .on('error', e => this.$emit('error', e)); }); }, - destroyed() { if (this.clipboard) { this.clipboard.destroy(); } }, - methods: { updateTooltip(target) { const $target = $(target); @@ -112,15 +104,12 @@ export default { }; </script> <template> - <gl-deprecated-button + <gl-button v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }" :class="cssClasses" :data-clipboard-target="target" :data-clipboard-text="text" :title="title" - > - <slot> - <gl-icon name="copy-to-clipboard" /> - </slot> - </gl-deprecated-button> + icon="copy-to-clipboard" + /> </template> diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue index f8983a3d29a..3749888ee36 100644 --- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue @@ -58,7 +58,12 @@ export default { active: tab.isActive, }" > - <a :class="`js-${scope}-tab-${tab.scope}`" role="button" @click="onTabClick(tab)"> + <a + :class="`js-${scope}-tab-${tab.scope}`" + :data-testid="`${scope}-tab-${tab.scope}`" + role="button" + @click="onTabClick(tab)" + > {{ tab.name }} <span v-if="shouldRenderBadge(tab.count)" class="badge badge-pill"> {{ tab.count }} </span> diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue index 53dbae39608..3aca068c074 100644 --- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -12,7 +12,7 @@ export default { </script> <template> - <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note"> + <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note_placeholder"> <div class="timeline-icon"></div> <div class="timeline-content"> <div class="note-header"></div> diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js new file mode 100644 index 00000000000..b7768cfa5b9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js @@ -0,0 +1,21 @@ +import { __ } from '~/locale'; + +export const tdClass = + 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap'; +export const thClass = 'gl-hover-bg-blue-50'; +export const bodyTrClass = + 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-b-solid gl-hover-border-blue-200'; + +export const defaultPageSize = 20; + +export const initialPaginationState = { + page: 1, + prevPageCursor: '', + nextPageCursor: '', + firstPageSize: defaultPageSize, + lastPageSize: null, +}; + +export const defaultI18n = { + searchPlaceholder: __('Search or filter results…'), +}; diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue new file mode 100644 index 00000000000..8e85d93e6d1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue @@ -0,0 +1,313 @@ +<script> +import { GlAlert, GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui'; +import Api from '~/api'; +import Tracking from '~/tracking'; +import { __ } from '~/locale'; +import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; +import { initialPaginationState, defaultI18n, defaultPageSize } from './constants'; +import { isAny } from './utils'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; + +export default { + defaultI18n, + components: { + GlAlert, + GlBadge, + GlPagination, + GlTabs, + GlTab, + FilteredSearchBar, + }, + inject: { + projectPath: { + default: '', + }, + textQuery: { + default: '', + }, + assigneeUsernameQuery: { + default: '', + }, + authorUsernameQuery: { + default: '', + }, + }, + props: { + items: { + type: Array, + required: true, + }, + itemsCount: { + type: Object, + required: false, + default: () => {}, + }, + pageInfo: { + type: Object, + required: false, + default: () => {}, + }, + statusTabs: { + type: Array, + required: true, + }, + showItems: { + type: Boolean, + required: false, + default: true, + }, + showErrorMsg: { + type: Boolean, + required: true, + }, + trackViewsOptions: { + type: Object, + required: true, + }, + i18n: { + type: Object, + required: true, + }, + serverErrorMessage: { + type: String, + required: false, + default: '', + }, + filterSearchKey: { + type: String, + required: true, + }, + filterSearchTokens: { + type: Array, + required: false, + default: () => ['author_username', 'assignee_username'], + }, + }, + data() { + return { + searchTerm: this.textQuery, + authorUsername: this.authorUsernameQuery, + assigneeUsername: this.assigneeUsernameQuery, + filterParams: {}, + pagination: initialPaginationState, + filteredByStatus: '', + statusFilter: '', + }; + }, + computed: { + defaultTokens() { + return [ + { + type: 'author_username', + icon: 'user', + title: __('Author'), + unique: true, + symbol: '@', + token: AuthorToken, + operators: [{ value: '=', description: __('is'), default: 'true' }], + fetchPath: this.projectPath, + fetchAuthors: Api.projectUsers.bind(Api), + }, + { + type: 'assignee_username', + icon: 'user', + title: __('Assignee'), + unique: true, + symbol: '@', + token: AuthorToken, + operators: [{ value: '=', description: __('is'), default: 'true' }], + fetchPath: this.projectPath, + fetchAuthors: Api.projectUsers.bind(Api), + }, + ]; + }, + filteredSearchTokens() { + return this.defaultTokens.filter(({ type }) => this.filterSearchTokens.includes(type)); + }, + filteredSearchValue() { + const value = []; + + if (this.authorUsername) { + value.push({ + type: 'author_username', + value: { data: this.authorUsername }, + }); + } + + if (this.assigneeUsername) { + value.push({ + type: 'assignee_username', + value: { data: this.assigneeUsername }, + }); + } + + if (this.searchTerm) { + value.push(this.searchTerm); + } + + return value; + }, + itemsForCurrentTab() { + return this.itemsCount?.[this.filteredByStatus.toLowerCase()] ?? 0; + }, + showPaginationControls() { + return Boolean(this.pageInfo?.hasNextPage || this.pageInfo?.hasPreviousPage); + }, + previousPage() { + return Math.max(this.pagination.page - 1, 0); + }, + nextPage() { + const nextPage = this.pagination.page + 1; + return nextPage > Math.ceil(this.itemsForCurrentTab / defaultPageSize) ? null : nextPage; + }, + }, + mounted() { + this.trackPageViews(); + }, + methods: { + filterItemsByStatus(tabIndex) { + this.resetPagination(); + const { filters, status } = this.statusTabs[tabIndex]; + this.statusFilter = filters; + this.filteredByStatus = status; + + this.$emit('tabs-changed', { filters, status }); + }, + handlePageChange(page) { + const { startCursor, endCursor } = this.pageInfo; + + if (page > this.pagination.page) { + this.pagination = { + ...initialPaginationState, + nextPageCursor: endCursor, + page, + }; + } else { + this.pagination = { + lastPageSize: defaultPageSize, + firstPageSize: null, + prevPageCursor: startCursor, + nextPageCursor: '', + page, + }; + } + + this.$emit('page-changed', this.pagination); + }, + resetPagination() { + this.pagination = initialPaginationState; + this.$emit('page-changed', this.pagination); + }, + handleFilterItems(filters) { + this.resetPagination(); + const filterParams = { authorUsername: '', assigneeUsername: '', search: '' }; + + filters.forEach(filter => { + if (typeof filter === 'object') { + switch (filter.type) { + case 'author_username': + filterParams.authorUsername = isAny(filter.value.data); + break; + case 'assignee_username': + filterParams.assigneeUsername = isAny(filter.value.data); + break; + case 'filtered-search-term': + if (filter.value.data !== '') filterParams.search = filter.value.data; + break; + default: + break; + } + } + }); + + this.filterParams = filterParams; + this.updateUrl(); + this.searchTerm = filterParams?.search; + this.authorUsername = filterParams?.authorUsername; + this.assigneeUsername = filterParams?.assigneeUsername; + + this.$emit('filters-changed', { + searchTerm: this.searchTerm, + authorUsername: this.authorUsername, + assigneeUsername: this.assigneeUsername, + }); + }, + updateUrl() { + const { authorUsername, assigneeUsername, search } = this.filterParams || {}; + + const params = { + ...(authorUsername !== '' && { author_username: authorUsername }), + ...(assigneeUsername !== '' && { assignee_username: assigneeUsername }), + ...(search !== '' && { search }), + }; + + updateHistory({ + url: setUrlParams(params, window.location.href, true), + title: document.title, + replace: true, + }); + }, + trackPageViews() { + const { category, action } = this.trackViewsOptions; + Tracking.event(category, action); + }, + }, +}; +</script> +<template> + <div class="incident-management-list"> + <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="$emit('error-alert-dismissed')"> + <!-- eslint-disable-next-line vue/no-v-html --> + <p v-html="serverErrorMessage || i18n.errorMsg"></p> + </gl-alert> + + <div + class="list-header gl-display-flex gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-gray-100" + > + <gl-tabs content-class="gl-p-0" @input="filterItemsByStatus"> + <gl-tab v-for="tab in statusTabs" :key="tab.status" :data-testid="tab.status"> + <template #title> + <span>{{ tab.title }}</span> + <gl-badge v-if="itemsCount" pill size="sm" class="gl-tab-counter-badge"> + {{ itemsCount[tab.status.toLowerCase()] }} + </gl-badge> + </template> + </gl-tab> + </gl-tabs> + + <slot name="header-actions"></slot> + </div> + + <div class="filtered-search-wrapper"> + <filtered-search-bar + :namespace="projectPath" + :search-input-placeholder="$options.defaultI18n.searchPlaceholder" + :tokens="filteredSearchTokens" + :initial-filter-value="filteredSearchValue" + initial-sortby="created_desc" + :recent-searches-storage-key="filterSearchKey" + class="row-content-block" + @onFilter="handleFilterItems" + /> + </div> + + <h4 class="gl-display-block d-md-none my-3"> + <slot name="title"></slot> + </h4> + + <slot v-if="showItems" name="table"></slot> + + <gl-pagination + v-if="showPaginationControls" + :value="pagination.page" + :prev-page="previousPage" + :next-page="nextPage" + align="center" + class="gl-pagination gl-mt-3" + @input="handlePageChange" + /> + + <slot v-if="!showItems" name="emtpy-state"></slot> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js new file mode 100644 index 00000000000..7de4263acbb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js @@ -0,0 +1,11 @@ +import { __ } from '~/locale'; + +/** + * Return a empty string when passed a value of 'Any' + * + * @param {String} value + * @returns {String} + */ +export const isAny = value => { + return value === __('Any') ? '' : value; +}; diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue index 50a19dc2156..7046ac5be03 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -39,7 +39,7 @@ export default { }, }, mounted() { - this.detailsSlots = Object.keys(this.$slots).filter(k => k.startsWith('details_')); + this.detailsSlots = Object.keys(this.$slots).filter(k => k.startsWith('details-')); }, methods: { toggleDetails() { diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue index cc33b8f85cd..06b4309ad42 100644 --- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue +++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue @@ -1,10 +1,12 @@ <script> -import { GlAvatar } from '@gitlab/ui'; +import { GlAvatar, GlSprintf, GlLink } from '@gitlab/ui'; export default { name: 'TitleArea', components: { GlAvatar, + GlSprintf, + GlLink, }, props: { avatar: { @@ -17,6 +19,11 @@ export default { default: null, required: false, }, + infoMessages: { + type: Array, + default: () => [], + required: false, + }, }, data() { return { @@ -24,43 +31,64 @@ export default { }; }, mounted() { - this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith('metadata_')); + this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith('metadata-')); }, }; </script> <template> - <div class="gl-display-flex gl-justify-content-space-between gl-py-3"> - <div class="gl-flex-direction-column"> - <div class="gl-display-flex"> - <gl-avatar v-if="avatar" :src="avatar" shape="rect" class="gl-align-self-center gl-mr-4" /> + <div class="gl-display-flex gl-flex-direction-column"> + <div class="gl-display-flex gl-justify-content-space-between gl-py-3"> + <div class="gl-flex-direction-column"> + <div class="gl-display-flex"> + <gl-avatar + v-if="avatar" + :src="avatar" + shape="rect" + class="gl-align-self-center gl-mr-4" + /> - <div class="gl-display-flex gl-flex-direction-column"> - <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2" data-testid="title"> - <slot name="title">{{ title }}</slot> - </h1> + <div class="gl-display-flex gl-flex-direction-column"> + <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2" data-testid="title"> + <slot name="title">{{ title }}</slot> + </h1> + + <div + v-if="$slots['sub-header']" + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + > + <slot name="sub-header"></slot> + </div> + </div> + </div> + <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"> <div - v-if="$slots['sub-header']" - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + v-for="(row, metadataIndex) in metadataSlots" + :key="metadataIndex" + class="gl-display-flex gl-align-items-center gl-mr-5" > - <slot name="sub-header"></slot> + <slot :name="row"></slot> </div> </div> </div> - - <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"> - <div - v-for="(row, metadataIndex) in metadataSlots" - :key="metadataIndex" - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <slot :name="row"></slot> - </div> + <div v-if="$slots['right-actions']" class="gl-mt-3"> + <slot name="right-actions"></slot> </div> </div> - <div v-if="$slots['right-actions']" class="gl-mt-3"> - <slot name="right-actions"></slot> - </div> + <p> + <span + v-for="(message, index) in infoMessages" + :key="index" + class="gl-mr-2" + data-testid="info-message" + > + <gl-sprintf :message="message.text"> + <template #docLink="{content}"> + <gl-link :href="message.link" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </p> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js index c08659919fa..cbb30baa488 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 @@ -2,8 +2,15 @@ import { __ } from '~/locale'; export const CUSTOM_EVENTS = { openAddImageModal: 'gl_openAddImageModal', + openInsertVideoModal: 'gl_openInsertVideoModal', }; +export const YOUTUBE_URL = 'https://www.youtube.com'; + +export const YOUTUBE_EMBED_URL = `${YOUTUBE_URL}/embed`; + +export const ALLOWED_VIDEO_ORIGINS = [YOUTUBE_URL]; + /* eslint-disable @gitlab/require-i18n-strings */ export const TOOLBAR_ITEM_CONFIGS = [ { icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') }, @@ -23,6 +30,7 @@ export const TOOLBAR_ITEM_CONFIGS = [ { icon: 'dash', command: 'HR', tooltip: __('Add a line') }, { icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') }, { icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') }, + { icon: 'live-preview', event: CUSTOM_EVENTS.openInsertVideoModal, tooltip: __('Insert video') }, { isDivider: true }, { icon: 'code', command: 'Code', tooltip: __('Insert inline code') }, { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') }, @@ -40,3 +48,10 @@ export const EDITOR_PREVIEW_STYLE = 'horizontal'; export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 }; export const MAX_FILE_SIZE = 2097152; // 2Mb + +export const VIDEO_ATTRIBUTES = { + width: '560', + height: '315', + frameBorder: '0', + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture', +}; 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 index 429a4e04110..e1652f54982 100644 --- 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 @@ -32,8 +32,8 @@ export default { uploadImageTab: null, }; }, - modalTitle: __('Image Details'), - okTitle: __('Insert'), + modalTitle: __('Image details'), + okTitle: __('Insert image'), urlTabTitle: __('By URL'), urlLabel: __('Image URL'), descriptionLabel: __('Description'), diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue new file mode 100644 index 00000000000..99bb2080610 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue @@ -0,0 +1,91 @@ +<script> +import { GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui'; +import { isSafeURL } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import { YOUTUBE_URL, YOUTUBE_EMBED_URL } from '../constants'; + +export default { + components: { + GlModal, + GlFormGroup, + GlFormInput, + GlSprintf, + }, + data() { + return { + url: null, + urlError: null, + description: __( + 'If the YouTube URL is https://www.youtube.com/watch?v=0t1DgySidms then the video ID is %{id}', + ), + }; + }, + modalTitle: __('Insert a video'), + okTitle: __('Insert video'), + label: __('YouTube URL or ID'), + methods: { + show() { + this.urlError = null; + this.url = null; + + this.$refs.modal.show(); + }, + onPrimary(event) { + this.submitURL(event); + }, + submitURL(event) { + const url = this.generateUrl(); + + if (!url) { + event.preventDefault(); + return; + } + + this.$emit('insertVideo', url); + }, + generateUrl() { + let { url } = this; + const reYouTubeId = /^[A-z0-9]*$/; + const reYouTubeUrl = RegExp(`${YOUTUBE_URL}/(embed/|watch\\?v=)([A-z0-9]+)`); + + if (reYouTubeId.test(url)) { + url = `${YOUTUBE_EMBED_URL}/${url}`; + } else if (reYouTubeUrl.test(url)) { + url = `${YOUTUBE_EMBED_URL}/${reYouTubeUrl.exec(url)[2]}`; + } + + if (!isSafeURL(url) || !reYouTubeUrl.test(url)) { + this.urlError = __('Please provide a valid YouTube URL or ID'); + this.$refs.urlInput.$el.focus(); + return null; + } + + return url; + }, + }, +}; +</script> +<template> + <gl-modal + ref="modal" + size="sm" + modal-id="insert-video-modal" + :title="$options.modalTitle" + :ok-title="$options.okTitle" + @primary="onPrimary" + > + <gl-form-group + :label="$options.label" + label-for="video-modal-url-input" + :state="!Boolean(urlError)" + :invalid-feedback="urlError" + > + <gl-form-input id="video-modal-url-input" ref="urlInput" v-model="url" /> + <gl-sprintf slot="description" :message="description" class="text-gl-muted"> + <template #id> + <strong>{{ __('0t1DgySidms') }}</strong> + </template> + </gl-sprintf> + </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 d96fe46522e..c2518441506 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 @@ -3,6 +3,7 @@ import 'codemirror/lib/codemirror.css'; import '@toast-ui/editor/dist/toastui-editor.css'; import AddImageModal from './modals/add_image/add_image_modal.vue'; +import InsertVideoModal from './modals/insert_video_modal.vue'; import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS } from './constants'; import { @@ -12,6 +13,7 @@ import { removeCustomEventListener, addImage, getMarkdown, + insertVideo, } from './services/editor_service'; export default { @@ -21,6 +23,7 @@ export default { toast => toast.Editor, ), AddImageModal, + InsertVideoModal, }, props: { content: { @@ -63,6 +66,12 @@ export default { editorInstance() { return this.$refs.editor; }, + customEventListeners() { + return [ + { event: CUSTOM_EVENTS.openAddImageModal, listener: this.onOpenAddImageModal }, + { event: CUSTOM_EVENTS.openInsertVideoModal, listener: this.onOpenInsertVideoModal }, + ]; + }, }, created() { this.editorOptions = getEditorOptions(this.options); @@ -72,16 +81,16 @@ export default { }, methods: { addListeners(editorApi) { - addCustomEventListener(editorApi, CUSTOM_EVENTS.openAddImageModal, this.onOpenAddImageModal); + this.customEventListeners.forEach(({ event, listener }) => { + addCustomEventListener(editorApi, event, listener); + }); editorApi.eventManager.listen('changeMode', this.onChangeMode); }, removeListeners() { - removeCustomEventListener( - this.editorApi, - CUSTOM_EVENTS.openAddImageModal, - this.onOpenAddImageModal, - ); + this.customEventListeners.forEach(({ event, listener }) => { + removeCustomEventListener(this.editorApi, event, listener); + }); this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode); }, @@ -111,6 +120,12 @@ export default { addImage(this.editorInstance, image); }, + onOpenInsertVideoModal() { + this.$refs.insertVideoModal.show(); + }, + onInsertVideo(url) { + insertVideo(this.editorInstance, url); + }, onChangeMode(newMode) { this.$emit('modeChange', newMode); }, @@ -130,5 +145,6 @@ export default { @load="onLoad" /> <add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" /> + <insert-video-modal ref="insertVideoModal" @insertVideo="onInsertVideo" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js index 51ba033dff0..8b3fbcabcfa 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js @@ -3,7 +3,8 @@ import { defaults } from 'lodash'; import ToolbarItem from '../toolbar_item.vue'; import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer'; import buildCustomHTMLRenderer from './build_custom_renderer'; -import { TOOLBAR_ITEM_CONFIGS } from '../constants'; +import { TOOLBAR_ITEM_CONFIGS, VIDEO_ATTRIBUTES } from '../constants'; +import sanitizeHTML from './sanitize_html'; const buildWrapper = propsData => { const instance = new Vue({ @@ -16,6 +17,23 @@ const buildWrapper = propsData => { return instance.$el; }; +const buildVideoIframe = src => { + const wrapper = document.createElement('figure'); + const iframe = document.createElement('iframe'); + const videoAttributes = { ...VIDEO_ATTRIBUTES, src }; + const wrapperClasses = ['gl-relative', 'gl-h-0', 'video_container']; + const iframeClasses = ['gl-absolute', 'gl-top-0', 'gl-left-0', 'gl-w-full', 'gl-h-full']; + + wrapper.setAttribute('contenteditable', 'false'); + wrapper.classList.add(...wrapperClasses); + iframe.classList.add(...iframeClasses); + Object.assign(iframe, videoAttributes); + + wrapper.appendChild(iframe); + + return wrapper; +}; + export const generateToolbarItem = config => { const { icon, classes, event, command, tooltip, isDivider } = config; @@ -43,6 +61,16 @@ export const removeCustomEventListener = (editorApi, event, handler) => export const addImage = ({ editor }, image) => editor.exec('AddImage', image); +export const insertVideo = ({ editor }, url) => { + const videoIframe = buildVideoIframe(url); + + if (editor.isWysiwygMode()) { + editor.getSquire().insertElement(videoIframe); + } else { + editor.insertText(videoIframe.outerHTML); + } +}; + export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown'); /** @@ -62,5 +90,6 @@ export const getEditorOptions = externalOptions => { return defaults({ customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers), toolbarItems: TOOLBAR_ITEM_CONFIGS.map(toolbarItem => generateToolbarItem(toolbarItem)), + customHTMLSanitizer: html => sanitizeHTML(html), }); }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js index b179ca61dba..18bd17d43d9 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js @@ -1,7 +1,21 @@ import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token'; +import { ALLOWED_VIDEO_ORIGINS } from '../../constants'; +import { getURLOrigin } from '~/lib/utils/url_utility'; -const canRender = ({ type }) => { - return type === 'htmlBlock'; +const isVideoFrame = html => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const { + children: { length }, + } = doc; + const iframe = doc.querySelector('iframe'); + const origin = iframe && getURLOrigin(iframe.getAttribute('src')); + + return length === 1 && ALLOWED_VIDEO_ORIGINS.includes(origin); +}; + +const canRender = ({ type, literal }) => { + return type === 'htmlBlock' && !isVideoFrame(literal); }; const render = node => buildUneditableHtmlAsTextTokens(node); diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js new file mode 100644 index 00000000000..eae2e0335c1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js @@ -0,0 +1,22 @@ +import createSanitizer from 'dompurify'; +import { ALLOWED_VIDEO_ORIGINS } from '../constants'; +import { getURLOrigin } from '~/lib/utils/url_utility'; + +const sanitizer = createSanitizer(window); +const ADD_TAGS = ['iframe']; + +sanitizer.addHook('uponSanitizeElement', node => { + if (node.tagName !== 'IFRAME') { + return; + } + + const origin = getURLOrigin(node.getAttribute('src')); + + if (!ALLOWED_VIDEO_ORIGINS.includes(origin)) { + node.remove(); + } +}); + +const sanitize = content => sanitizer.sanitize(content, { ADD_TAGS }); + +export default sanitize; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue index 0ed5a050fe4..6511c8d8c31 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue @@ -1,11 +1,10 @@ <script> -import { GlIcon } from '@gitlab/ui'; -import tooltip from '~/vue_shared/directives/tooltip'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; export default { name: 'CollapsedCalendarIcon', directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlIcon, @@ -41,16 +40,7 @@ export default { </script> <template> - <div - v-tooltip - :class="containerClass" - :title="tooltipText" - data-container="body" - data-placement="left" - data-html="true" - data-boundary="viewport" - @click="click" - > + <div v-gl-tooltip.left.viewport :class="containerClass" :title="tooltipText" @click="click"> <gl-icon v-if="showIcon" name="calendar" /> <slot> <span> {{ text }} </span> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue index 6839354fb3a..267c3be5f50 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue @@ -38,6 +38,7 @@ export default { <template> <div class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute" + data-qa-selector="labels_dropdown_content" :style="directionStyle" > <component :is="dropdownContentsView" /> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index 0b763aa4b72..c8dee81d746 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -1,6 +1,7 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; import { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink } from '@gitlab/ui'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; @@ -39,9 +40,9 @@ export default { ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']), visibleLabels() { if (this.searchKey) { - return this.labels.filter(label => - label.title.toLowerCase().includes(this.searchKey.toLowerCase()), - ); + return fuzzaldrinPlus.filter(this.labels, this.searchKey, { + key: ['title'], + }); } return this.labels; }, @@ -112,6 +113,7 @@ export default { this.currentHighlightItem += 1; } else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) { this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]); + this.searchKey = ''; } else if (e.keyCode === ESC_KEY_CODE) { this.toggleDropdownContents(); } @@ -155,7 +157,11 @@ export default { /> </div> <div class="dropdown-input" @click.stop="() => {}"> - <gl-search-box-by-type v-model="searchKey" :autofocus="true" /> + <gl-search-box-by-type + v-model="searchKey" + :autofocus="true" + data-qa-selector="dropdown_input_field" + /> </div> <div v-show="showListContainer" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue index 12ad2acf308..a6f99289df4 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue @@ -8,8 +8,20 @@ export default { components: { GlLabel, }, + props: { + disableLabels: { + type: Boolean, + required: false, + default: false, + }, + }, computed: { - ...mapState(['selectedLabels', 'allowScopedLabels', 'labelsFilterBasePath']), + ...mapState([ + 'selectedLabels', + 'allowLabelRemove', + 'allowScopedLabels', + 'labelsFilterBasePath', + ]), }, methods: { labelFilterUrl(label) { @@ -35,12 +47,17 @@ export default { <template v-for="label in selectedLabels" v-else> <gl-label :key="label.id" + data-qa-selector="selected_label_content" + :data-qa-label-name="label.title" :title="label.title" :description="label.description" :background-color="label.color" :target="labelFilterUrl(label)" :scoped="scopedLabel(label)" + :show-close-button="allowLabelRemove" + :disabled="disableLabels" tooltip-placement="top" + @close="$emit('onLabelRemove', label.id)" /> </template> </div> 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 34f5517ef99..c651013c5f5 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 @@ -28,6 +28,11 @@ export default { DropdownValueCollapsed, }, props: { + allowLabelRemove: { + type: Boolean, + required: false, + default: false, + }, allowLabelEdit: { type: Boolean, required: true, @@ -130,6 +135,7 @@ export default { mounted() { this.setInitialState({ variant: this.variant, + allowLabelRemove: this.allowLabelRemove, allowLabelEdit: this.allowLabelEdit, allowLabelCreate: this.allowLabelCreate, allowMultiselect: this.allowMultiselect, @@ -252,7 +258,10 @@ export default { :allow-label-edit="allowLabelEdit" :labels-select-in-progress="labelsSelectInProgress" /> - <dropdown-value> + <dropdown-value + :disable-labels="labelsSelectInProgress" + @onLabelRemove="$emit('onLabelRemove', $event)" + > <slot></slot> </dropdown-value> <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" /> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js index 2d236566b3d..e624bd1eaee 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js @@ -54,8 +54,5 @@ export const createLabel = ({ state, dispatch }, label) => { }); }; -export const replaceSelectedLabels = ({ commit }, selectedLabels) => - commit(types.REPLACE_SELECTED_LABELS, selectedLabels); - export const updateSelectedLabels = ({ commit }, labels) => commit(types.UPDATE_SELECTED_LABELS, { labels }); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js index af92665d4eb..2e044dc3b3c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js @@ -15,7 +15,6 @@ export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE'; export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY'; export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS'; -export const REPLACE_SELECTED_LABELS = 'REPLACE_SELECTED_LABELS'; export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS'; export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW'; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js index 7edd290a819..54f8c78b4e1 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js @@ -57,10 +57,6 @@ export default { state.labelCreateInProgress = false; }, - [types.REPLACE_SELECTED_LABELS](state, selectedLabels = []) { - state.selectedLabels = selectedLabels; - }, - [types.UPDATE_SELECTED_LABELS](state, { labels }) { // Find the label to update from all the labels // and change `set` prop value to represent their current state. 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 3f3358d4805..d66cfed4163 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 @@ -15,6 +15,7 @@ export default () => ({ // UI Flags variant: '', + allowLabelRemove: false, allowLabelCreate: false, allowLabelEdit: false, allowScopedLabels: false, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue index 040a15406e0..6dacf4e10d3 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue @@ -1,11 +1,14 @@ <script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; export default { name: 'ToggleSidebar', + components: { + GlButton, + }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { collapsed: { @@ -22,6 +25,12 @@ export default { tooltipLabel() { return this.collapsed ? __('Expand sidebar') : __('Collapse sidebar'); }, + buttonIcon() { + return this.collapsed ? 'chevron-double-lg-left' : 'chevron-double-lg-right'; + }, + allCssClasses() { + return [this.cssClasses, { 'js-sidebar-collapsed': this.collapsed }]; + }, }, methods: { toggle() { @@ -32,25 +41,15 @@ export default { </script> <template> - <button - v-tooltip + <gl-button + v-gl-tooltip:body.viewport.left :title="tooltipLabel" - :class="cssClasses" - type="button" - class="btn btn-blank gutter-toggle btn-sidebar-action js-sidebar-vue-toggle" - data-container="body" - data-placement="left" - data-boundary="viewport" + :class="allCssClasses" + class="gutter-toggle btn-sidebar-action js-sidebar-vue-toggle" + :icon="buttonIcon" + category="tertiary" + size="small" + :aria-label="__('toggle collapse')" @click="toggle" - > - <i - :class="{ - 'fa-angle-double-right': !collapsed, - 'fa-angle-double-left': collapsed, - }" - :aria-label="__('toggle collapse')" - class="fa" - > - </i> - </button> + /> </template> diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue index e9b99c6ea78..11049028ff6 100644 --- a/app/assets/javascripts/vue_shared/components/split_button.vue +++ b/app/assets/javascripts/vue_shared/components/split_button.vue @@ -1,19 +1,15 @@ <script> import { isString } from 'lodash'; -import { - GlDeprecatedDropdown, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownItem, -} from '@gitlab/ui'; +import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui'; const isValidItem = item => isString(item.eventName) && isString(item.title) && isString(item.description); export default { components: { - GlDeprecatedDropdown, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, }, props: { @@ -32,7 +28,7 @@ export default { variant: { type: String, required: false, - default: 'secondary', + default: 'default', }, }, @@ -61,8 +57,8 @@ export default { </script> <template> - <gl-deprecated-dropdown - :menu-class="`dropdown-menu-selectable ${menuClass}`" + <gl-dropdown + :menu-class="menuClass" split :text="dropdownToggleText" :variant="variant" @@ -70,20 +66,20 @@ export default { @click="triggerEvent" > <template v-for="(item, itemIndex) in actionItems"> - <gl-deprecated-dropdown-item + <gl-dropdown-item :key="item.eventName" - :active="selectedItem === item" - active-class="is-active" + :is-check-item="true" + :is-checked="selectedItem === item" @click="changeSelectedItem(item)" > <strong>{{ item.title }}</strong> <div>{{ item.description }}</div> - </gl-deprecated-dropdown-item> + </gl-dropdown-item> - <gl-deprecated-dropdown-divider + <gl-dropdown-divider v-if="itemIndex < actionItems.length - 1" :key="`${item.eventName}-divider`" /> </template> - </gl-deprecated-dropdown> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue index a0c161a335a..f2e9c4a4fbb 100644 --- a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue +++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue @@ -1,11 +1,11 @@ <script> +import { GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import { roundOffFloat } from '~/lib/utils/common_utils'; -import tooltip from '~/vue_shared/directives/tooltip'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { cssClass: { @@ -112,7 +112,7 @@ export default { <span v-if="!totalCount" class="status-unavailable">{{ unavailableLabel }}</span> <span v-if="successPercent" - v-tooltip + v-gl-tooltip :title="successTooltip" :style="successBarStyle" class="status-green" @@ -122,7 +122,7 @@ export default { </span> <span v-if="neutralPercent" - v-tooltip + v-gl-tooltip :title="neutralTooltip" :style="neutralBarStyle" class="status-neutral" @@ -132,7 +132,7 @@ export default { </span> <span v-if="failurePercent" - v-tooltip + v-gl-tooltip :title="failureTooltip" :style="failureBarStyle" class="status-red" diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue index 135b9842cbf..f6721f5a27b 100644 --- a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDeprecatedDropdownItem, GlSearchBoxByType, GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; import { __ } from '~/locale'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; @@ -7,9 +7,8 @@ export default { name: 'TimezoneDropdown', components: { GlDropdown, - GlDeprecatedDropdownItem, + GlDropdownItem, GlSearchBoxByType, - GlIcon, }, directives: { autofocusonshow, @@ -74,29 +73,23 @@ export default { }; </script> <template> - <gl-dropdown :text="value" block lazy menu-class="gl-w-full!"> - <template #button-content> - <span class="gl-flex-grow-1" :class="{ 'gl-text-gray-300': !value }"> - {{ selectedTimezoneLabel }} - </span> - <gl-icon name="chevron-down" /> - </template> - - <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus class="gl-m-3" /> - <gl-deprecated-dropdown-item + <gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!"> + <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus /> + <gl-dropdown-item v-for="timezone in filteredResults" :key="timezone.formattedTimezone" + :is-checked="isSelected(timezone)" + :is-check-item="true" @click="selectTimezone(timezone)" > - <gl-icon - :class="{ invisible: !isSelected(timezone) }" - name="mobile-issue-close" - class="gl-vertical-align-middle" - /> {{ timezone.formattedTimezone }} - </gl-deprecated-dropdown-item> - <gl-deprecated-dropdown-item v-if="!filteredResults.length" data-testid="noMatchingResults"> + </gl-dropdown-item> + <gl-dropdown-item + v-if="!filteredResults.length" + class="gl-pointer-events-none" + data-testid="noMatchingResults" + > {{ $options.tranlations.noResultsText }} - </gl-deprecated-dropdown-item> + </gl-dropdown-item> </gl-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/todo_button.vue b/app/assets/javascripts/vue_shared/components/todo_button.vue index debf19ccca6..a9d4f8403fa 100644 --- a/app/assets/javascripts/vue_shared/components/todo_button.vue +++ b/app/assets/javascripts/vue_shared/components/todo_button.vue @@ -15,7 +15,7 @@ export default { }, computed: { buttonLabel() { - return this.isTodo ? __('Mark as done') : __('Add a To-Do'); + return this.isTodo ? __('Mark as done') : __('Add a To Do'); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue index 29d4516bece..861661d3519 100644 --- a/app/assets/javascripts/vue_shared/components/toggle_button.vue +++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue @@ -59,7 +59,7 @@ export default { </script> <template> - <label class="toggle-wrapper"> + <label class="gl-mt-2"> <input v-if="name" :name="name" :value="value" type="hidden" /> <button type="button" 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 6aaff000845..3f5738b2b93 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 @@ -1,16 +1,27 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlPopover, GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlIcon } from '@gitlab/ui'; +import { + GlPopover, + GlLink, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlIcon, +} from '@gitlab/ui'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; const MAX_SKELETON_LINES = 4; +const SECURITY_BOT_USER_DATA = { + username: 'GitLab-Security-Bot', + name: 'GitLab Security Bot', +}; + export default { name: 'UserPopover', maxSkeletonLines: MAX_SKELETON_LINES, components: { GlIcon, + GlLink, GlPopover, GlSkeletonLoading, UserAvatarImage, @@ -43,6 +54,15 @@ export default { userIsLoading() { return !this.user?.loaded; }, + isSecurityBot() { + const { username, name, websiteUrl = '' } = this.user; + return ( + gon.features?.securityAutoFix && + username === SECURITY_BOT_USER_DATA.username && + name === SECURITY_BOT_USER_DATA.name && + websiteUrl.length + ); + }, }, }; </script> @@ -89,6 +109,12 @@ export default { <div v-if="statusHtml" class="js-user-status gl-mt-3"> <span v-html="statusHtml"></span> </div> + <div v-if="isSecurityBot" class="gl-text-blue-500"> + <gl-icon name="question" /> + <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl"> + {{ sprintf(__('Learn more about %{username}'), { username: user.name }) }} + </gl-link> + </div> </template> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 8307c6d3b55..877414519f7 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -4,6 +4,7 @@ import { __ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; +const KEY_EDIT = 'edit'; const KEY_WEB_IDE = 'webide'; const KEY_GITPOD = 'gitpod'; @@ -13,15 +14,31 @@ export default { LocalStorageSync, }, props: { - webIdeUrl: { - type: String, - required: true, + isFork: { + type: Boolean, + required: false, + default: false, }, needsToFork: { type: Boolean, required: false, default: false, }, + gitpodEnabled: { + type: Boolean, + required: false, + default: false, + }, + isBlob: { + type: Boolean, + required: false, + default: false, + }, + showEditButton: { + type: Boolean, + required: false, + default: true, + }, showWebIdeButton: { type: Boolean, required: false, @@ -32,15 +49,20 @@ export default { required: false, default: false, }, - gitpodUrl: { + editUrl: { type: String, required: false, default: '', }, - gitpodEnabled: { - type: Boolean, + webIdeUrl: { + type: String, required: false, - default: false, + default: '', + }, + gitpodUrl: { + type: String, + required: false, + default: '', }, }, data() { @@ -50,7 +72,33 @@ export default { }, computed: { actions() { - return [this.webIdeAction, this.gitpodAction].filter(x => x); + return [this.webIdeAction, this.editAction, this.gitpodAction].filter(action => action); + }, + editAction() { + if (!this.showEditButton) { + return null; + } + + const handleOptions = this.needsToFork + ? { + href: '#modal-confirm-fork-edit', + handle: () => this.showModal('#modal-confirm-fork-edit'), + } + : { href: this.editUrl }; + + return { + key: KEY_EDIT, + text: __('Edit'), + secondaryText: __('Edit this file only.'), + tooltip: '', + attrs: { + 'data-qa-selector': 'edit_button', + 'data-track-event': 'click_edit', + // eslint-disable-next-line @gitlab/require-i18n-strings + 'data-track-label': 'Edit', + }, + ...handleOptions, + }; }, webIdeAction() { if (!this.showWebIdeButton) { @@ -58,16 +106,30 @@ export default { } const handleOptions = this.needsToFork - ? { href: '#modal-confirm-fork', handle: () => this.showModal('#modal-confirm-fork') } + ? { + href: '#modal-confirm-fork-webide', + handle: () => this.showModal('#modal-confirm-fork-webide'), + } : { href: this.webIdeUrl }; + let text = __('Web IDE'); + + if (this.isBlob) { + text = __('Edit in Web IDE'); + } else if (this.isFork) { + text = __('Edit fork in Web IDE'); + } + return { key: KEY_WEB_IDE, - text: __('Web IDE'), + text, secondaryText: __('Quickly and easily edit multiple files in your project.'), tooltip: '', attrs: { 'data-qa-selector': 'web_ide_button', + 'data-track-event': 'click_edit_ide', + // eslint-disable-next-line @gitlab/require-i18n-strings + 'data-track-label': 'Web IDE', }, ...handleOptions, }; @@ -107,8 +169,14 @@ export default { </script> <template> - <div> - <actions-button :actions="actions" :selected-key="selection" @select="select" /> + <div class="d-inline-block gl-ml-3"> + <actions-button + :actions="actions" + :selected-key="selection" + :variant="isBlob ? 'info' : 'default'" + :category="isBlob ? 'primary' : 'secondary'" + @select="select" + /> <local-storage-sync storage-key="gl-web-ide-button-selected" :value="selection" diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js index 73e92728cb9..0eb505bfce8 100644 --- a/app/assets/javascripts/vue_shared/directives/tooltip.js +++ b/app/assets/javascripts/vue_shared/directives/tooltip.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import '~/commons/bootstrap'; +import { parseBoolean } from '~/lib/utils/common_utils'; export default { bind(el) { @@ -9,6 +10,10 @@ export default { $(el).tooltip({ trigger: 'hover', delay, + // By default, sanitize is run even if there is no `html` or `template` present + // so let's optimize to only run this when necessary. + // https://github.com/twbs/bootstrap/blob/c5966de27395a407f9a3d20d0eb2ff8e8fb7b564/js/src/tooltip.js#L716 + sanitize: parseBoolean(el.dataset.html) || Boolean(el.dataset.template), }); }, diff --git a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js index a740a3fa6b9..cdbde55901d 100644 --- a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js @@ -10,6 +10,10 @@ import { validateParams } from '~/pipelines/utils'; export default { methods: { onChangeTab(scope) { + if (this.scope === scope) { + return; + } + let params = { scope, page: '1', diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js index be5f55a5220..c0fc055a01b 100644 --- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js @@ -111,7 +111,7 @@ const mixins = { return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length; }, isOpen() { - return this.state === 'opened'; + return this.state === 'opened' || this.state === 'reopened'; }, isClosed() { return this.state === 'closed'; diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue new file mode 100644 index 00000000000..d5696e3c8cf --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue @@ -0,0 +1,107 @@ +<script> +import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import ReportSection from '~/reports/components/report_section.vue'; +import { status } from '~/reports/constants'; +import { s__ } from '~/locale'; +import Flash from '~/flash'; +import Api from '~/api'; + +export default { + components: { + GlIcon, + GlLink, + GlSprintf, + ReportSection, + }, + props: { + pipelineId: { + type: Number, + required: true, + }, + projectId: { + type: Number, + required: true, + }, + securityReportsDocsPath: { + type: String, + required: true, + }, + }, + data() { + return { + hasSecurityReports: false, + + // Error state is shown even when successfully loaded, since success + // state suggests that the security scans detected no security problems, + // which is not necessarily the case. A future iteration will actually + // check whether problems were found and display the appropriate status. + status: status.ERROR, + }; + }, + created() { + this.checkHasSecurityReports(this.$options.reportTypes) + .then(hasSecurityReports => { + this.hasSecurityReports = hasSecurityReports; + }) + .catch(error => { + Flash({ + message: this.$options.i18n.apiError, + captureError: true, + error, + }); + }); + }, + methods: { + checkHasSecurityReports(reportTypes) { + return Api.pipelineJobs(this.projectId, this.pipelineId).then(({ data: jobs }) => + jobs.some(({ artifacts = [] }) => + artifacts.some(({ file_type }) => reportTypes.includes(file_type)), + ), + ); + }, + activatePipelinesTab() { + if (window.mrTabs) { + window.mrTabs.tabShown('pipelines'); + } + }, + }, + reportTypes: ['sast', 'secret_detection'], + i18n: { + apiError: s__( + 'SecurityReports|Failed to get security report information. Please reload the page or try again later.', + ), + scansHaveRun: s__( + 'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', + ), + securityReportsHelp: s__('SecurityReports|Security reports help page link'), + }, +}; +</script> +<template> + <report-section + v-if="hasSecurityReports" + :status="status" + :has-issues="false" + class="mr-widget-border-top mr-report" + data-testid="security-mr-widget" + > + <template #error> + <gl-sprintf :message="$options.i18n.scansHaveRun"> + <template #link="{ content }"> + <gl-link data-testid="show-pipelines" @click="activatePipelinesTab">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + + <gl-link + target="_blank" + data-testid="help" + :href="securityReportsDocsPath" + :aria-label="$options.i18n.securityReportsHelp" + > + <gl-icon name="question" /> + </gl-link> + </template> + </report-section> +</template> diff --git a/app/assets/javascripts/vuex_shared/modules/members/actions.js b/app/assets/javascripts/vuex_shared/modules/members/actions.js new file mode 100644 index 00000000000..f7fdddfd070 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/members/actions.js @@ -0,0 +1,25 @@ +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; + +export const updateMemberRole = async ({ state, commit }, { memberId, accessLevel }) => { + try { + await axios.put( + state.memberPath.replace(/:id$/, memberId), + state.requestFormatter({ accessLevel: accessLevel.integerValue }), + ); + + commit(types.RECEIVE_MEMBER_ROLE_SUCCESS, { memberId, accessLevel }); + } catch (error) { + commit(types.RECEIVE_MEMBER_ROLE_ERROR); + + throw error; + } +}; + +export const showRemoveGroupLinkModal = ({ commit }, groupLink) => { + commit(types.SHOW_REMOVE_GROUP_LINK_MODAL, groupLink); +}; + +export const hideRemoveGroupLinkModal = ({ commit }) => { + commit(types.HIDE_REMOVE_GROUP_LINK_MODAL); +}; diff --git a/app/assets/javascripts/vuex_shared/modules/members/index.js b/app/assets/javascripts/vuex_shared/modules/members/index.js index ec6a94178f3..682e85298ad 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/index.js +++ b/app/assets/javascripts/vuex_shared/modules/members/index.js @@ -1,6 +1,10 @@ -import createState from './state'; +import createState from 'ee_else_ce/vuex_shared/modules/members/state'; +import * as actions from './actions'; +import mutations from './mutations'; export default initialState => ({ namespaced: true, state: createState(initialState), + actions, + mutations, }); diff --git a/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js b/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js new file mode 100644 index 00000000000..00f4c910669 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js @@ -0,0 +1,7 @@ +export const RECEIVE_MEMBER_ROLE_SUCCESS = 'RECEIVE_MEMBER_ROLE_SUCCESS'; +export const RECEIVE_MEMBER_ROLE_ERROR = 'RECEIVE_MEMBER_ROLE_ERROR'; + +export const HIDE_ERROR = 'HIDE_ERROR'; + +export const SHOW_REMOVE_GROUP_LINK_MODAL = 'SHOW_REMOVE_GROUP_LINK_MODAL'; +export const HIDE_REMOVE_GROUP_LINK_MODAL = 'HIDE_REMOVE_GROUP_LINK_MODAL'; diff --git a/app/assets/javascripts/vuex_shared/modules/members/mutations.js b/app/assets/javascripts/vuex_shared/modules/members/mutations.js new file mode 100644 index 00000000000..281c947e68f --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/members/mutations.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import { s__ } from '~/locale'; +import * as types from './mutation_types'; +import { findMember } from './utils'; + +export default { + [types.RECEIVE_MEMBER_ROLE_SUCCESS](state, { memberId, accessLevel }) { + const member = findMember(state, memberId); + + if (!member) { + return; + } + + Vue.set(member, 'accessLevel', accessLevel); + }, + [types.RECEIVE_MEMBER_ROLE_ERROR](state) { + state.errorMessage = s__( + "Members|An error occurred while updating the member's role, please try again.", + ); + state.showError = true; + }, + [types.HIDE_ERROR](state) { + state.showError = false; + state.errorMessage = ''; + }, + [types.SHOW_REMOVE_GROUP_LINK_MODAL](state, groupLink) { + state.removeGroupLinkModalVisible = true; + state.groupLinkToRemove = groupLink; + }, + [types.HIDE_REMOVE_GROUP_LINK_MODAL](state) { + state.removeGroupLinkModalVisible = false; + }, +}; diff --git a/app/assets/javascripts/vuex_shared/modules/members/state.js b/app/assets/javascripts/vuex_shared/modules/members/state.js index 1511961245c..e4867819e17 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/state.js +++ b/app/assets/javascripts/vuex_shared/modules/members/state.js @@ -1,5 +1,19 @@ -export default ({ members, sourceId, currentUserId }) => ({ +export default ({ members, sourceId, currentUserId, + tableFields, + memberPath, + requestFormatter, +}) => ({ + members, + sourceId, + currentUserId, + tableFields, + memberPath, + requestFormatter, + showError: false, + errorMessage: '', + removeGroupLinkModalVisible: false, + groupLinkToRemove: null, }); diff --git a/app/assets/javascripts/vuex_shared/modules/members/utils.js b/app/assets/javascripts/vuex_shared/modules/members/utils.js new file mode 100644 index 00000000000..7dcd33111e8 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/members/utils.js @@ -0,0 +1 @@ +export const findMember = (state, memberId) => state.members.find(member => member.id === memberId); diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue index a00661c214d..9400dacedc2 100644 --- a/app/assets/javascripts/whats_new/components/app.vue +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -1,6 +1,10 @@ <script> import { mapState, mapActions } from 'vuex'; import { GlDrawer, GlBadge, GlIcon, GlLink } from '@gitlab/ui'; +import SkeletonLoader from './skeleton_loader.vue'; +import Tracking from '~/tracking'; + +const trackingMixin = Tracking.mixin(); export default { components: { @@ -8,66 +12,90 @@ export default { GlBadge, GlIcon, GlLink, + SkeletonLoader, }, + mixins: [trackingMixin], props: { - features: { + storageKey: { type: String, - required: false, + required: true, default: null, }, }, computed: { - ...mapState(['open']), - parsedFeatures() { - let features; - - try { - features = JSON.parse(this.$props.features) || []; - } catch (err) { - features = []; - } - - return features; - }, + ...mapState(['open', 'features']), }, mounted() { - this.openDrawer(); + this.openDrawer(this.storageKey); + this.fetchItems(); + + const body = document.querySelector('body'); + const namespaceId = body.getAttribute('data-namespace-id'); + + this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId }); }, methods: { - ...mapActions(['openDrawer', 'closeDrawer']), + ...mapActions(['openDrawer', 'closeDrawer', 'fetchItems']), }, }; </script> <template> <div> - <gl-drawer class="mt-6" :open="open" @close="closeDrawer"> + <gl-drawer class="whats-new-drawer" :open="open" @close="closeDrawer"> <template #header> <h4 class="page-title my-2">{{ __("What's new at GitLab") }}</h4> </template> <div class="pb-6"> - <div v-for="feature in parsedFeatures" :key="feature.title" class="mb-6"> - <gl-link :href="feature.url" target="_blank"> - <h5 class="gl-font-base">{{ feature.title }}</h5> - </gl-link> - <div class="mb-2"> - <template v-for="package_name in feature.packages"> - <gl-badge :key="package_name" size="sm" class="whats-new-item-badge mr-1"> - <gl-icon name="license" />{{ package_name }} - </gl-badge> - </template> + <template v-if="features"> + <div v-for="feature in features" :key="feature.title" class="mb-6"> + <gl-link + :href="feature.url" + target="_blank" + data-testid="whats-new-title-link" + data-track-event="click_whats_new_item" + :data-track-label="feature.title" + :data-track-property="feature.url" + > + <h5 class="gl-font-base">{{ feature.title }}</h5> + </gl-link> + <div v-if="feature.packages" class="gl-mb-3"> + <template v-for="package_name in feature.packages"> + <gl-badge :key="package_name" size="sm" class="whats-new-item-badge gl-mr-2"> + <gl-icon name="license" />{{ package_name }} + </gl-badge> + </template> + </div> + <gl-link + :href="feature.url" + target="_blank" + data-track-event="click_whats_new_item" + :data-track-label="feature.title" + :data-track-property="feature.url" + > + <img + :alt="feature.title" + :src="feature.image_url" + class="img-thumbnail px-6 gl-py-3 whats-new-item-image" + /> + </gl-link> + <p class="gl-pt-3">{{ feature.body }}</p> + <gl-link + :href="feature.url" + target="_blank" + data-track-event="click_whats_new_item" + :data-track-label="feature.title" + :data-track-property="feature.url" + >{{ __('Learn more') }}</gl-link + > </div> - <gl-link :href="feature.url" target="_blank"> - <img - :alt="feature.title" - :src="feature.image_url" - class="img-thumbnail px-6 py-2 whats-new-item-image" - /> - </gl-link> - <p class="pt-2">{{ feature.body }}</p> - <gl-link :href="feature.url" target="_blank">{{ __('Learn more') }}</gl-link> + </template> + <div v-else class="gl-mt-5"> + <skeleton-loader /> + <skeleton-loader /> </div> </div> </gl-drawer> + <div v-if="open" class="whats-new-modal-backdrop modal-backdrop"></div> </div> </template> diff --git a/app/assets/javascripts/whats_new/components/skeleton_loader.vue b/app/assets/javascripts/whats_new/components/skeleton_loader.vue new file mode 100644 index 00000000000..41e7790f300 --- /dev/null +++ b/app/assets/javascripts/whats_new/components/skeleton_loader.vue @@ -0,0 +1,25 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, +}; +</script> + +<template> + <gl-skeleton-loader :width="350" :height="420"> + <rect width="350" height="16" /> + <rect y="25" width="110" height="16" rx="8" /> + <rect x="115" y="25" width="110" height="16" rx="8" /> + <rect x="230" y="25" width="110" height="16" rx="8" /> + <rect y="50" width="350" height="165" rx="12" /> + <rect y="230" width="480" height="8" /> + <rect y="254" width="560" height="8" /> + <rect y="278" width="320" height="8" /> + <rect y="302" width="480" height="8" /> + <rect y="326" width="560" height="8" /> + <rect y="365" width="80" height="8" /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js index 19cdb590ae2..a57c9718156 100644 --- a/app/assets/javascripts/whats_new/index.js +++ b/app/assets/javascripts/whats_new/index.js @@ -19,7 +19,7 @@ export default () => { render(createElement) { return createElement('app', { props: { - features: whatsNewElm.getAttribute('data-features'), + storageKey: whatsNewElm.getAttribute('data-storage-key'), }, }); }, diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js index 53488413d9e..a84dfb399d8 100644 --- a/app/assets/javascripts/whats_new/store/actions.js +++ b/app/assets/javascripts/whats_new/store/actions.js @@ -1,10 +1,20 @@ import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; export default { closeDrawer({ commit }) { commit(types.CLOSE_DRAWER); }, - openDrawer({ commit }) { + openDrawer({ commit }, storageKey) { commit(types.OPEN_DRAWER); + + if (storageKey) { + localStorage.setItem(storageKey, JSON.stringify(false)); + } + }, + fetchItems({ commit }) { + return axios.get('/-/whats_new').then(({ data }) => { + commit(types.SET_FEATURES, data); + }); }, }; diff --git a/app/assets/javascripts/whats_new/store/mutation_types.js b/app/assets/javascripts/whats_new/store/mutation_types.js index daa65230101..124d33a88b1 100644 --- a/app/assets/javascripts/whats_new/store/mutation_types.js +++ b/app/assets/javascripts/whats_new/store/mutation_types.js @@ -1,2 +1,3 @@ export const CLOSE_DRAWER = 'CLOSE_DRAWER'; export const OPEN_DRAWER = 'OPEN_DRAWER'; +export const SET_FEATURES = 'SET_FEATURES'; diff --git a/app/assets/javascripts/whats_new/store/mutations.js b/app/assets/javascripts/whats_new/store/mutations.js index f7e84ee81a9..4fb7b17244e 100644 --- a/app/assets/javascripts/whats_new/store/mutations.js +++ b/app/assets/javascripts/whats_new/store/mutations.js @@ -7,4 +7,7 @@ export default { [types.OPEN_DRAWER](state) { state.open = true; }, + [types.SET_FEATURES](state, data) { + state.features = data; + }, }; diff --git a/app/assets/javascripts/whats_new/store/state.js b/app/assets/javascripts/whats_new/store/state.js index 97089a095f1..4c76284b865 100644 --- a/app/assets/javascripts/whats_new/store/state.js +++ b/app/assets/javascripts/whats_new/store/state.js @@ -1,3 +1,4 @@ export default { open: false, + features: null, }; |