diff options
Diffstat (limited to 'app/assets/javascripts')
504 files changed, 9432 insertions, 3953 deletions
diff --git a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql index 09278e1776a..cdc8a952ead 100644 --- a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql +++ b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql @@ -22,6 +22,7 @@ query accessTokensGetProjects( avatarUrl } pageInfo { + __typename ...PageInfo } } diff --git a/app/assets/javascripts/actioncable_link.js b/app/assets/javascripts/actioncable_link.js index 895a34ba157..cf53d9e21b4 100644 --- a/app/assets/javascripts/actioncable_link.js +++ b/app/assets/javascripts/actioncable_link.js @@ -1,4 +1,4 @@ -import { ApolloLink, Observable } from 'apollo-link'; +import { ApolloLink, Observable } from '@apollo/client/core'; import { print } from 'graphql'; import cable from '~/actioncable_consumer'; import { uuids } from '~/lib/utils/uuids'; diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js index f45af5fe08e..74e0e1b6225 100644 --- a/app/assets/javascripts/activities.js +++ b/app/assets/javascripts/activities.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ import $ from 'jquery'; -import Cookies from 'js-cookie'; +import { setCookie } from '~/lib/utils/common_utils'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import { localTimeAgo } from './lib/utils/datetime_utility'; @@ -55,7 +55,7 @@ export default class Activities { const filter = $sender.attr('id').split('_')[0]; $('.event-filter .active').removeClass('active'); - Cookies.set('event_filter', filter); + setCookie('event_filter', filter); $sender.closest('li').toggleClass('active'); } diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue index 90c9113e0e1..96584080d0f 100644 --- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue +++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue @@ -1,5 +1,5 @@ <script> -import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf } from '@gitlab/ui'; +import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf, GlBadge } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue'; import createFlash from '~/flash'; @@ -21,6 +21,7 @@ export default { ReviewTabContainer, GlSearchBoxByType, GlSprintf, + GlBadge, }, props: { contextCommitsPath: { @@ -239,7 +240,7 @@ export default { <template #title> <gl-sprintf :message="__(`Commits in %{codeStart}${targetBranch}%{codeEnd}`)"> <template #code="{ content }"> - <code>{{ content }}</code> + <code class="gl-ml-2">{{ content }}</code> </template> </gl-sprintf> </template> @@ -262,7 +263,7 @@ export default { <gl-tab> <template #title> {{ __('Selected commits') }} - <span class="badge badge-pill">{{ selectedCommitsCount }}</span> + <gl-badge size="sm" class="gl-ml-2">{{ selectedCommitsCount }}</gl-badge> </template> <review-tab-container :is-loading="isLoadingContextCommits" diff --git a/app/assets/javascripts/admin/application_settings/setup_service_usage_data.js b/app/assets/javascripts/admin/application_settings/setup_service_usage_data.js new file mode 100644 index 00000000000..a88efbd89a8 --- /dev/null +++ b/app/assets/javascripts/admin/application_settings/setup_service_usage_data.js @@ -0,0 +1,15 @@ +import PayloadPreviewer from '~/pages/admin/application_settings/payload_previewer'; +import PayloadDownloader from '~/pages/admin/application_settings/payload_downloader'; + +export default () => { + const payloadPreviewTrigger = document.querySelector('.js-payload-preview-trigger'); + const payloadDownloadTrigger = document.querySelector('.js-payload-download-trigger'); + + if (payloadPreviewTrigger) { + new PayloadPreviewer(payloadPreviewTrigger).init(); + } + + if (payloadDownloadTrigger) { + new PayloadDownloader(payloadDownloadTrigger).init(); + } +}; 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 79a6bac3ba7..84c2b216859 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -15,7 +15,7 @@ import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql'; import { fetchPolicies } from '~/lib/graphql'; import { convertToSnakeCase } from '~/lib/utils/text_utility'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; -import { s__, __ } from '~/locale'; +import { s__, __, n__ } from '~/locale'; import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue'; import { tdClass, @@ -32,8 +32,11 @@ const TH_TEST_ID = { 'data-testid': 'alert-management-severity-sort' }; const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000; +const MAX_VISIBLE_ASSIGNEES = 4; + export default { trackAlertListViewsOptions, + MAX_VISIBLE_ASSIGNEES, i18n: { noAlertsMsg: s__( 'AlertManagement|No alerts available to display. See %{linkStart}enabling alert management%{linkEnd} for more information on adding alerts to the list.', @@ -258,6 +261,13 @@ export default { this.serverErrorMessage = ''; this.isErrorAlertDismissed = true; }, + assigneesBadgeSrOnlyText(item) { + return n__( + '%d additional assignee', + '%d additional assignees', + item.assignees.nodes.length - MAX_VISIBLE_ASSIGNEES, + ); + }, }, }; </script> @@ -365,10 +375,11 @@ export default { <gl-avatars-inline :avatars="item.assignees.nodes" :collapsed="true" - :max-visible="4" + :max-visible="$options.MAX_VISIBLE_ASSIGNEES" :avatar-size="24" badge-tooltip-prop="name" :badge-tooltip-max-chars="100" + :badge-sr-only-text="assigneesBadgeSrOnlyText(item)" > <template #avatar="{ avatar }"> <gl-avatar-link diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js index b23f8a8eba4..42cbeef56bf 100644 --- a/app/assets/javascripts/alert_management/list.js +++ b/app/assets/javascripts/alert_management/list.js @@ -1,4 +1,4 @@ -import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import { defaultDataIdFromObject } from '@apollo/client/core'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; diff --git a/app/assets/javascripts/alerts_settings/graphql.js b/app/assets/javascripts/alerts_settings/graphql.js index b64e2e3eefa..36a98145457 100644 --- a/app/assets/javascripts/alerts_settings/graphql.js +++ b/app/assets/javascripts/alerts_settings/graphql.js @@ -1,15 +1,9 @@ -import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import introspectionQueryResultData from './graphql/fragmentTypes.json'; import getCurrentIntegrationQuery from './graphql/queries/get_current_integration.query.graphql'; -const fragmentMatcher = new IntrospectionFragmentMatcher({ - introspectionQueryResultData, -}); - Vue.use(VueApollo); const resolvers = { @@ -55,9 +49,5 @@ const resolvers = { }; export default new VueApollo({ - defaultClient: createDefaultClient(resolvers, { - cacheConfig: { - fragmentMatcher, - }, - }), + defaultClient: createDefaultClient(resolvers), }); diff --git a/app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json b/app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json deleted file mode 100644 index 07dfc43aa6c..00000000000 --- a/app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json +++ /dev/null @@ -1 +0,0 @@ -{"__schema":{"types":[{"kind":"UNION","name":"AlertManagementIntegration","possibleTypes":[{"name":"AlertManagementHttpIntegration"},{"name":"AlertManagementPrometheusIntegration"}]}]}} diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql index 3cd3f2d92f8..ac9304391f9 100644 --- a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql @@ -5,6 +5,7 @@ query getIntegrations($projectPath: ID!) { id alertManagementIntegrations { nodes { + __typename ...IntegrationItem } } diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue index a5b9c40b9c9..7df66d1b2be 100644 --- a/app/assets/javascripts/analytics/shared/components/daterange.vue +++ b/app/assets/javascripts/analytics/shared/components/daterange.vue @@ -1,5 +1,5 @@ <script> -import { GlDaterangePicker, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlDaterangePicker, GlSprintf } from '@gitlab/ui'; import { getDayDifference } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; import { OFFSET_DATE_BY_ONE } from '../constants'; @@ -8,10 +8,6 @@ export default { components: { GlDaterangePicker, GlSprintf, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, }, props: { show: { @@ -56,7 +52,7 @@ export default { return { maxDateRangeTooltip: sprintf( __( - 'Showing data for workflow items created in this date range. Date range cannot exceed %{maxDateRange} days.', + 'Showing data for workflow items created in this date range. Date range limited to %{maxDateRange} days.', ), { maxDateRange: this.maxDateRange, @@ -94,28 +90,15 @@ export default { :max-date-range="maxDateRange" :default-max-date="maxDate" :same-day-selection="includeSelectedDate" + :tooltip="maxDateRangeTooltip" theme="animate-picker" start-picker-class="js-daterange-picker-from gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-lg-mr-3 gl-mb-2 gl-lg-mb-0" - end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center" + end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center gl-mb-2 gl-lg-mb-0" label-class="gl-mb-2 gl-lg-mb-0" - /> - <div - v-if="maxDateRange" - class="daterange-indicator d-flex flex-row flex-lg-row align-items-flex-start align-items-lg-center" > - <span class="number-of-days pl-2 pr-1"> - <gl-sprintf :message="n__('1 day selected', '%d days selected', numberOfDays)"> - <template #numberOfDays>{{ numberOfDays }}</template> - </gl-sprintf> - </span> - <gl-icon - v-gl-tooltip - data-testid="helper-icon" - :title="maxDateRangeTooltip" - name="question" - :size="14" - class="text-secondary" - /> - </div> + <gl-sprintf :message="n__('1 day selected', '%d days selected', numberOfDays)"> + <template #numberOfDays>{{ numberOfDays }}</template> + </gl-sprintf> + </gl-daterange-picker> </div> </template> diff --git a/app/assets/javascripts/cycle_analytics/components/metric_popover.vue b/app/assets/javascripts/analytics/shared/components/metric_popover.vue index 8d90e7b2392..8d90e7b2392 100644 --- a/app/assets/javascripts/cycle_analytics/components/metric_popover.vue +++ b/app/assets/javascripts/analytics/shared/components/metric_popover.vue diff --git a/app/assets/javascripts/analytics/shared/components/metric_tile.vue b/app/assets/javascripts/analytics/shared/components/metric_tile.vue new file mode 100644 index 00000000000..845a3386f6c --- /dev/null +++ b/app/assets/javascripts/analytics/shared/components/metric_tile.vue @@ -0,0 +1,51 @@ +<script> +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { redirectTo } from '~/lib/utils/url_utility'; +import MetricPopover from './metric_popover.vue'; + +export default { + name: 'MetricTile', + components: { + GlSingleStat, + MetricPopover, + }, + props: { + metric: { + type: Object, + required: true, + }, + }, + computed: { + decimalPlaces() { + const parsedFloat = parseFloat(this.metric.value); + return Number.isNaN(parsedFloat) || Number.isInteger(parsedFloat) ? 0 : 1; + }, + hasLinks() { + return this.metric.links?.length && this.metric.links[0].url; + }, + }, + methods: { + clickHandler({ links }) { + if (this.hasLinks) { + redirectTo(links[0].url); + } + }, + }, +}; +</script> +<template> + <div v-bind="$attrs"> + <gl-single-stat + :id="metric.identifier" + :value="`${metric.value}`" + :title="metric.label" + :unit="metric.unit || ''" + :should-animate="true" + :animation-decimal-places="decimalPlaces" + :class="{ 'gl-hover-cursor-pointer': hasLinks }" + tabindex="0" + @click="clickHandler(metric)" + /> + <metric-popover :metric="metric" :target="metric.identifier" /> + </div> +</template> diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue index 9671742e564..1a3544e7677 100644 --- a/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue +++ b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue @@ -1,13 +1,11 @@ <script> import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; -import { GlSingleStat } from '@gitlab/ui/dist/charts'; -import { flatten } from 'lodash'; +import { flatten, isEqual } from 'lodash'; import createFlash from '~/flash'; import { sprintf, s__ } from '~/locale'; -import { redirectTo } from '~/lib/utils/url_utility'; import { METRICS_POPOVER_CONTENT } from '../constants'; import { removeFlash, prepareTimeMetricsData } from '../utils'; -import MetricPopover from './metric_popover.vue'; +import MetricTile from './metric_tile.vue'; const requestData = ({ request, endpoint, path, params, name }) => { return request({ endpoint, params, requestPath: path }) @@ -33,9 +31,8 @@ const fetchMetricsData = (reqs = [], path, params) => { export default { name: 'ValueStreamMetrics', components: { - GlSingleStat, GlSkeletonLoading, - MetricPopover, + MetricTile, }, props: { requestPath: { @@ -50,6 +47,11 @@ export default { type: Array, required: true, }, + filterFn: { + type: Function, + required: false, + default: null, + }, }, data() { return { @@ -58,8 +60,10 @@ export default { }; }, watch: { - requestParams() { - this.fetchData(); + requestParams(newVal, oldVal) { + if (!isEqual(newVal, oldVal)) { + this.fetchData(); + } }, }, mounted() { @@ -71,40 +75,25 @@ export default { this.isLoading = true; return fetchMetricsData(this.requests, this.requestPath, this.requestParams) .then((data) => { - this.metrics = data; + this.metrics = this.filterFn ? this.filterFn(data) : data; this.isLoading = false; }) .catch(() => { this.isLoading = false; }); }, - hasLinks(links) { - return links?.length && links[0].url; - }, - clickHandler({ links }) { - if (this.hasLinks(links)) { - redirectTo(links[0].url); - } - }, }, }; </script> <template> - <div class="gl-display-flex gl-flex-wrap" data-testid="vsa-time-metrics"> + <div class="gl-display-flex gl-flex-wrap" data-testid="vsa-metrics"> <gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6" /> - <div v-for="metric in metrics" v-show="!isLoading" :key="metric.key" class="gl-my-6 gl-pr-9"> - <gl-single-stat - :id="metric.key" - :value="`${metric.value}`" - :title="metric.label" - :unit="metric.unit || ''" - :should-animate="true" - :animation-decimal-places="1" - :class="{ 'gl-hover-cursor-pointer': hasLinks(metric.links) }" - tabindex="0" - @click="clickHandler(metric)" - /> - <metric-popover :metric="metric" :target="metric.key" /> - </div> + <metric-tile + v-for="metric in metrics" + v-show="!isLoading" + :key="metric.identifier" + :metric="metric" + class="gl-my-6 gl-pr-9" + /> </div> </template> diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js index c06bd34f86f..2ac144ceb5e 100644 --- a/app/assets/javascripts/analytics/shared/constants.js +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -1,4 +1,5 @@ import { masks } from 'dateformat'; +import { s__ } from '~/locale'; export const DATE_RANGE_LIMIT = 180; export const OFFSET_DATE_BY_ONE = 1; @@ -11,3 +12,47 @@ export const dateFormats = { defaultDateTime: 'mmm d, yyyy h:MMtt', month: 'mmmm', }; + +// Some content is duplicated due to backward compatibility. +// It will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/350614 in 14.9 +export const METRICS_POPOVER_CONTENT = { + 'lead-time': { + description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'), + }, + lead_time: { + description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'), + }, + 'cycle-time': { + description: s__( + "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.", + ), + }, + cycle_time: { + description: s__( + "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.", + ), + }, + 'lead-time-for-changes': { + description: s__( + 'ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period.', + ), + }, + lead_time_for_changes: { + description: s__( + 'ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period.', + ), + }, + issues: { description: s__('ValueStreamAnalytics|Number of new issues created.') }, + 'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') }, + 'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') }, + deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') }, + 'deployment-frequency': { + description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'), + }, + deployment_frequency: { + description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'), + }, + commits: { + description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'), + }, +}; diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js index f55ef99964e..dde429ab278 100644 --- a/app/assets/javascripts/analytics/shared/utils.js +++ b/app/assets/javascripts/analytics/shared/utils.js @@ -1,4 +1,6 @@ import dateFormat from 'dateformat'; +import { hideFlash } from '~/flash'; +import { slugify } from '~/lib/utils/text_utility'; import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { dateFormats } from './constants'; @@ -69,3 +71,28 @@ export const getDataZoomOption = ({ }; }); }; + +export const removeFlash = (type = 'alert') => { + const flashEl = document.querySelector(`.flash-${type}`); + if (flashEl) { + hideFlash(flashEl); + } +}; + +/** + * Prepares metric data to be rendered in the metric_card component + * + * @param {MetricData[]} data - The metric data to be rendered + * @param {Object} popoverContent - Key value pair of data to display in the popover + * @returns {TransformedMetricData[]} An array of metrics ready to render in the metric_card + */ +export const prepareTimeMetricsData = (data = [], popoverContent = {}) => + data.map(({ title: label, identifier, ...rest }) => { + const metricIdentifier = identifier || slugify(label); + return { + ...rest, + label, + identifier: metricIdentifier, + description: popoverContent[metricIdentifier]?.description || '', + }; + }); diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql index 2bde5973600..b353bcdfd0e 100644 --- a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql +++ b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql @@ -1,4 +1,5 @@ fragment Count on UsageTrendsMeasurement { + __typename count recordedAt } diff --git a/app/assets/javascripts/authentication/webauthn/util.js b/app/assets/javascripts/authentication/webauthn/util.js index 5f06c000afe..eeda2bfaeaf 100644 --- a/app/assets/javascripts/authentication/webauthn/util.js +++ b/app/assets/javascripts/authentication/webauthn/util.js @@ -14,31 +14,36 @@ export function isHTTPS() { export const FLOW_AUTHENTICATE = 'authenticate'; export const FLOW_REGISTER = 'register'; -// adapted from https://stackoverflow.com/a/21797381/8204697 -function base64ToBuffer(base64) { - const binaryString = window.atob(base64); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i += 1) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes.buffer; -} - -// adapted from https://stackoverflow.com/a/9458996/8204697 -function bufferToBase64(buffer) { - if (typeof buffer === 'string') { - return buffer; +/** + * Converts a base64 string to an ArrayBuffer + * + * @param {String} str - A base64 encoded string + * @returns {ArrayBuffer} + */ +export const base64ToBuffer = (str) => { + const rawStr = atob(str); + const buffer = new ArrayBuffer(rawStr.length); + const arr = new Uint8Array(buffer); + for (let i = 0; i < rawStr.length; i += 1) { + arr[i] = rawStr.charCodeAt(i); } + return arr.buffer; +}; - let binary = ''; - const bytes = new Uint8Array(buffer); - const len = bytes.byteLength; - for (let i = 0; i < len; i += 1) { - binary += String.fromCharCode(bytes[i]); +/** + * Converts ArrayBuffer to a base64-encoded string + * + * @param {ArrayBuffer, String} str - + * @returns {String} - ArrayBuffer to a base64-encoded string. + * When input is a string, returns the input as-is. + */ +export const bufferToBase64 = (input) => { + if (typeof input === 'string') { + return input; } - return window.btoa(binary); -} + const arr = new Uint8Array(input); + return btoa(String.fromCharCode(...arr)); +}; /** * Returns a copy of the given object with the id property converted to buffer diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 43ca5b5cf89..aa735df7da5 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -2,10 +2,10 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; -import Cookies from 'js-cookie'; import { uniq } from 'lodash'; +import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils'; import * as Emoji from '~/emoji'; -import { scrollToElement } from '~/lib/utils/common_utils'; + import { dispose, fixTitle } from '~/tooltips'; import createFlash from './flash'; import axios from './lib/utils/axios_utils'; @@ -506,7 +506,7 @@ export class AwardsHandler { addEmojiToFrequentlyUsedList(emoji) { if (this.emoji.isEmojiNameValid(emoji)) { this.frequentlyUsedEmojis = uniq(this.getFrequentlyUsedEmojis().concat(emoji)); - Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 }); + setCookie('frequently_used_emojis', this.frequentlyUsedEmojis.join(',')); } } @@ -514,7 +514,7 @@ export class AwardsHandler { return ( this.frequentlyUsedEmojis || (() => { - const frequentlyUsedEmojis = uniq((Cookies.get('frequently_used_emojis') || '').split(',')); + const frequentlyUsedEmojis = uniq((getCookie('frequently_used_emojis') || '').split(',')); this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter((inputName) => this.emoji.isEmojiNameValid(inputName), ); diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index 53469ac8999..8bef972cc58 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -74,7 +74,14 @@ export default { <template> <div> - <a v-show="!isLoading && !hasError" :href="linkUrl" target="_blank" rel="noopener noreferrer"> + <a + v-show="!isLoading && !hasError" + :href="linkUrl" + target="_blank" + rel="noopener noreferrer" + data-qa-selector="badge_image_link" + :data-qa-link-url="linkUrl" + > <img :src="imageUrlWithRetries" class="project-badge" diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index 2c7e878f044..d1570e16639 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -182,7 +182,7 @@ export default { @submit.prevent.stop="onSubmit" > <gl-form-group :label="s__('Badges|Name')" label-for="badge-name"> - <gl-form-input id="badge-name" v-model="name" /> + <gl-form-input id="badge-name" v-model="name" data-qa-selector="badge_name_field" /> </gl-form-group> <div class="form-group"> @@ -191,6 +191,7 @@ export default { <input id="badge-link-url" v-model="linkUrl" + data-qa-selector="badge_link_url_field" type="URL" class="form-control gl-form-input" required @@ -206,6 +207,7 @@ export default { <input id="badge-image-url" v-model="imageUrl" + data-qa-selector="badge_image_url_field" type="URL" class="form-control gl-form-input" required @@ -246,7 +248,13 @@ export default { </gl-button> </div> <div v-else class="form-group"> - <gl-button :loading="isSaving" type="submit" variant="confirm" category="primary"> + <gl-button + :loading="isSaving" + type="submit" + variant="confirm" + category="primary" + data-qa-selector="add_badge_button" + > {{ s__('Badges|Add badge') }} </gl-button> </div> diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue index 86c7b4c7a6e..76625fe9a60 100644 --- a/app/assets/javascripts/badges/components/badge_list.vue +++ b/app/assets/javascripts/badges/components/badge_list.vue @@ -34,8 +34,14 @@ export default { <span v-if="isGroupBadge">{{ s__('Badges|This group has no badges') }}</span> <span v-else>{{ s__('Badges|This project has no badges') }}</span> </div> - <div v-else class="card-body"> - <badge-list-row v-for="badge in badges" :key="badge.id" :badge="badge" /> + <div v-else class="card-body" data-qa-selector="badge_list_content"> + <badge-list-row + v-for="badge in badges" + :key="badge.id" + :badge="badge" + data-qa-selector="badge_list_row" + :data-qa-badge-name="badge.name" + /> </div> </div> </template> diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue index d8525c15087..4c2b700c7ff 100644 --- a/app/assets/javascripts/badges/components/badge_list_row.vue +++ b/app/assets/javascripts/badges/components/badge_list_row.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon, GlButton, GlModalDirective } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton, GlModalDirective, GlBadge } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; import { s__ } from '~/locale'; import { PROJECT_BADGE } from '../constants'; @@ -11,6 +11,7 @@ export default { Badge, GlLoadingIcon, GlButton, + GlBadge, }, directives: { GlModal: GlModalDirective, @@ -49,7 +50,7 @@ export default { /> <div class="table-section section-30"> <label class="label-bold str-truncated mb-0">{{ badge.name }}</label> - <span class="badge badge-pill">{{ badgeKindText }}</span> + <gl-badge size="sm">{{ badgeKindText }}</gl-badge> </div> <span class="table-section section-30 str-truncated">{{ badge.linkUrl }}</span> <div class="table-section section-10 table-button-footer"> diff --git a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue index 570954c7200..2ebde10c229 100644 --- a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue +++ b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue @@ -1,11 +1,13 @@ <script> import { mapGetters } from 'vuex'; import imageDiff from '~/diffs/mixins/image_diff'; +import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; import DraftNote from './draft_note.vue'; export default { components: { DraftNote, + DesignNotePin, }, mixins: [imageDiff], props: { @@ -31,9 +33,12 @@ export default { class="discussion-notes diff-discussions position-relative" > <div class="notes"> - <span class="d-block btn-transparent badge badge-pill is-draft js-diff-notes-index"> - {{ toggleText(draft, index) }} - </span> + <design-note-pin + :label="toggleText(draft, index)" + is-draft + class="js-diff-notes-index gl-translate-x-n50" + size="sm" + /> <draft-note :draft="draft" /> </div> </div> diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index a218624f2d4..c8130c47f5b 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlButton, GlSafeHtmlDirective, GlBadge } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import NoteableNote from '~/notes/components/noteable_note.vue'; import PublishButton from './publish_button.vue'; @@ -9,6 +9,7 @@ export default { NoteableNote, PublishButton, GlButton, + GlBadge, }, directives: { SafeHtml: GlSafeHtmlDirective, @@ -100,9 +101,7 @@ export default { @toggleResolveStatus="toggleResolveDiscussion(draft.id)" > <template #note-header-info> - <strong class="badge draft-pending-label gl-mr-2"> - {{ __('Pending') }} - </strong> + <gl-badge variant="warning" class="gl-mr-2">{{ __('Pending') }}</gl-badge> </template> </noteable-note> </ul> @@ -115,10 +114,15 @@ export default { ></div> <p class="draft-note-actions d-flex"> - <publish-button :show-count="true" :should-publish="false" category="secondary" /> + <publish-button + :show-count="true" + :should-publish="false" + category="secondary" + :disabled="isPublishingDraft(draft.id)" + /> <gl-button - ref="publishNowButton" - :loading="isPublishingDraft(draft.id) || isPublishing" + :disabled="isPublishing" + :loading="isPublishingDraft(draft.id)" class="gl-ml-3" @click="publishNow" > diff --git a/app/assets/javascripts/behaviors/markdown/marks/bold.js b/app/assets/javascripts/behaviors/markdown/marks/bold.js index d307edd9fd3..89e373220af 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/bold.js +++ b/app/assets/javascripts/behaviors/markdown/marks/bold.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { Bold as BaseBold } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter export default class Bold extends BaseBold { diff --git a/app/assets/javascripts/behaviors/markdown/marks/code.js b/app/assets/javascripts/behaviors/markdown/marks/code.js index ccfe2cf5b8d..68368dec676 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/code.js +++ b/app/assets/javascripts/behaviors/markdown/marks/code.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { Code as BaseCode } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter export default class Code extends BaseCode { diff --git a/app/assets/javascripts/behaviors/markdown/marks/italic.js b/app/assets/javascripts/behaviors/markdown/marks/italic.js index dbef10536ab..7dc86102f18 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/italic.js +++ b/app/assets/javascripts/behaviors/markdown/marks/italic.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { Italic as BaseItalic } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter export default class Italic extends BaseItalic { diff --git a/app/assets/javascripts/behaviors/markdown/marks/link.js b/app/assets/javascripts/behaviors/markdown/marks/link.js index 1111c51805d..b5e09017d83 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/link.js +++ b/app/assets/javascripts/behaviors/markdown/marks/link.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { Link as BaseLink } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter export default class Link extends BaseLink { diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js index 382bf5c9b5b..ca25ff7d07d 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/math.js +++ b/app/assets/javascripts/behaviors/markdown/marks/math.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { Mark } from 'tiptap'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; // Transforms generated HTML back to GFM for Banzai::Filter::MathFilter diff --git a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js index bd5868e5524..8b14a04e2fe 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { Blockquote as BaseBlockquote } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter export default class Blockquote extends BaseBlockquote { diff --git a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js index 209e7239998..ef1eafaa419 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { BulletList as BaseBulletList } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter export default class BulletList extends BaseBulletList { diff --git a/app/assets/javascripts/behaviors/markdown/nodes/heading.js b/app/assets/javascripts/behaviors/markdown/nodes/heading.js index 708da053a2f..29967e61ffa 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/heading.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/heading.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { Heading as BaseHeading } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter export default class Heading extends BaseHeading { diff --git a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js index 47a24eae1e8..ee3aa145dc3 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { HorizontalRule as BaseHorizontalRule } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter export default class HorizontalRule extends BaseHorizontalRule { diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js index 4cc28c45739..16647d2f96e 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/image.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js @@ -1,8 +1,8 @@ /* eslint-disable class-methods-use-this */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { Image as BaseImage } from 'tiptap-extensions'; import { placeholderImage } from '~/lazy_loader'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; export default class Image extends BaseImage { diff --git a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js index 0f56e89dca6..7204b7c09ba 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { ListItem as BaseListItem } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter export default class ListItem extends BaseListItem { diff --git a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js index 93d00f27868..5fd098cd46f 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter export default class Paragraph extends Node { diff --git a/app/assets/javascripts/behaviors/markdown/nodes/playable.js b/app/assets/javascripts/behaviors/markdown/nodes/playable.js index 2b667aba2d6..90cbaf9ef4c 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/playable.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/playable.js @@ -1,8 +1,8 @@ /* eslint-disable class-methods-use-this */ /* eslint-disable @gitlab/require-i18n-strings */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; /** * Abstract base class for playable media, like video and audio. diff --git a/app/assets/javascripts/behaviors/markdown/nodes/text.js b/app/assets/javascripts/behaviors/markdown/nodes/text.js index 4eab10c9d98..0dc77a12f5c 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/text.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/text.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ -import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; export default class Text extends Node { get name() { diff --git a/app/assets/javascripts/behaviors/markdown/serializer.js b/app/assets/javascripts/behaviors/markdown/serializer.js index b4adf1a413f..a5f97d7748a 100644 --- a/app/assets/javascripts/behaviors/markdown/serializer.js +++ b/app/assets/javascripts/behaviors/markdown/serializer.js @@ -1,4 +1,4 @@ -import { MarkdownSerializer } from 'prosemirror-markdown'; +import { MarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; import editorExtensions from './editor_extensions'; const nodes = editorExtensions diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index ac2a4184176..9297b14aac9 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -1,9 +1,9 @@ import $ from 'jquery'; -import Cookies from 'js-cookie'; import { flatten } from 'lodash'; import Mousetrap from 'mousetrap'; import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils'; + import findAndFollowLink from '~/lib/utils/navigation_utility'; import { refreshCurrentPage, visitUrl } from '~/lib/utils/url_utility'; import { @@ -161,10 +161,10 @@ export default class Shortcuts { static onTogglePerfBar(e) { e.preventDefault(); const performanceBarCookieName = 'perf_bar_enabled'; - if (parseBoolean(Cookies.get(performanceBarCookieName))) { - Cookies.set(performanceBarCookieName, 'false', { expires: 365, path: '/' }); + if (parseBoolean(getCookie(performanceBarCookieName))) { + setCookie(performanceBarCookieName, 'false', { path: '/' }); } else { - Cookies.set(performanceBarCookieName, 'true', { expires: 365, path: '/' }); + setCookie(performanceBarCookieName, 'true', { path: '/' }); } refreshCurrentPage(); } @@ -172,8 +172,13 @@ export default class Shortcuts { static onToggleCanary(e) { e.preventDefault(); const canaryCookieName = 'gitlab_canary'; - const currentValue = parseBoolean(Cookies.get(canaryCookieName)); - Cookies.set(canaryCookieName, (!currentValue).toString(), { expires: 365, path: '/' }); + const currentValue = parseBoolean(getCookie(canaryCookieName)); + setCookie(canaryCookieName, (!currentValue).toString(), { + expires: 365, + path: '/', + // next.gitlab.com uses a leading period. See https://gitlab.com/gitlab-org/gitlab/-/issues/350186 + domain: `.${window.location.hostname}`, + }); refreshCurrentPage(); } diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index 1645469a218..c5ab28e6ec5 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -42,6 +42,11 @@ export default { required: false, default: false, }, + showPath: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -55,6 +60,9 @@ export default { showDefaultActions() { return !this.hideDefaultActions; }, + isEmpty() { + return this.blob.rawSize === 0; + }, }, watch: { viewer(newVal, oldVal) { @@ -74,7 +82,7 @@ export default { <div class="js-file-title file-title-flex-parent"> <div class="gl-display-flex"> <table-of-contents class="gl-pr-2" /> - <blob-filepath :blob="blob"> + <blob-filepath :blob="blob" :show-path="showPath"> <template #filepath-prepend> <slot name="prepend"></slot> </template> @@ -88,10 +96,13 @@ export default { <default-actions v-if="showDefaultActions" - :raw-path="blob.rawPath" + :raw-path="blob.externalStorageUrl || blob.rawPath" :active-viewer="viewer" :has-render-error="hasRenderError" :is-binary="isBinary" + :environment-name="blob.environmentFormattedExternalUrl" + :environment-path="blob.environmentExternalUrlForRouteMap" + :is-empty="isEmpty" @copy="proxyCopyRequest" /> </div> diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue index 2798a918b15..12bcb24b0cc 100644 --- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue +++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; import { BTN_COPY_CONTENTS_TITLE, BTN_DOWNLOAD_TITLE, @@ -37,6 +38,21 @@ export default { required: false, default: false, }, + environmentName: { + type: String, + required: false, + default: null, + }, + environmentPath: { + type: String, + required: false, + default: null, + }, + isEmpty: { + type: Boolean, + required: false, + default: false, + }, }, computed: { downloadUrl() { @@ -51,6 +67,11 @@ export default { showCopyButton() { return !this.hasRenderError && !this.isBinary; }, + environmentTitle() { + return sprintf(s__('BlobViewer|View on %{environmentName}'), { + environmentName: this.environmentName, + }); + }, }, BTN_COPY_CONTENTS_TITLE, BTN_DOWNLOAD_TITLE, @@ -71,6 +92,7 @@ export default { icon="copy-to-clipboard" category="primary" variant="default" + class="js-copy-blob-source-btn" /> <gl-button v-if="!isBinary" @@ -84,6 +106,7 @@ export default { variant="default" /> <gl-button + v-if="!isEmpty" v-gl-tooltip.hover :aria-label="$options.BTN_DOWNLOAD_TITLE" :title="$options.BTN_DOWNLOAD_TITLE" @@ -93,5 +116,17 @@ export default { category="primary" variant="default" /> + <gl-button + v-if="environmentName && environmentPath" + v-gl-tooltip.hover + :aria-label="environmentTitle" + :title="environmentTitle" + :href="environmentPath" + data-testid="environment" + target="_blank" + icon="external-link" + category="primary" + variant="default" + /> </gl-button-group> </template> diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue index 90d01358451..62355306655 100644 --- a/app/assets/javascripts/blob/components/blob_header_filepath.vue +++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue @@ -15,6 +15,11 @@ export default { type: Object, required: true, }, + showPath: { + type: Boolean, + required: false, + default: true, + }, }, computed: { blobSize() { @@ -26,6 +31,13 @@ export default { showLfsBadge() { return this.blob.storedExternally && this.blob.externalStorage === 'lfs'; }, + fileName() { + if (this.showPath) { + return this.blob.path; + } + + return this.blob.name; + }, }, }; </script> @@ -33,12 +45,12 @@ export default { <div class="file-header-content d-flex align-items-center lh-100"> <slot name="filepath-prepend"></slot> - <template v-if="blob.path"> - <file-icon :file-name="blob.path" :size="16" aria-hidden="true" css-classes="mr-2" /> + <template v-if="fileName"> + <file-icon :file-name="fileName" :size="16" aria-hidden="true" css-classes="mr-2" /> <strong class="file-title-name mr-1 js-blob-header-filepath" data-qa-selector="file_title_content" - >{{ blob.path }}</strong + >{{ fileName }}</strong > </template> diff --git a/app/assets/javascripts/blob/components/constants.js b/app/assets/javascripts/blob/components/constants.js index a129c537fa5..adac4d6408d 100644 --- a/app/assets/javascripts/blob/components/constants.js +++ b/app/assets/javascripts/blob/components/constants.js @@ -42,7 +42,7 @@ export const BLOB_RENDER_ERRORS = { id: 'load', text: __('load it anyway'), conjunction: __('or'), - href: '#', + href: '?expanded=true&viewer=simple', target: '', event: BLOB_RENDER_EVENT_LOAD, }, diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue index 47a0c4ba2d1..b4ca29114cb 100644 --- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue +++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue @@ -1,6 +1,6 @@ <script> import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; -import Cookies from 'js-cookie'; +import { getCookie, removeCookie } from '~/lib/utils/common_utils'; import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; @@ -62,7 +62,7 @@ export default { return this.commitCookiePath || this.projectMergeRequestsPath; }, commitCookiePath() { - const cookieVal = Cookies.get(this.commitCookie); + const cookieVal = getCookie(this.commitCookie); if (cookieVal !== 'true') return cookieVal; return ''; @@ -85,7 +85,7 @@ export default { }, methods: { disableModalFromRenderingAgain() { - Cookies.remove(this.commitCookie); + removeCookie(this.commitCookie); }, }, }; diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index ea80496c3f5..aee61a5b2a5 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -11,12 +11,10 @@ import { sortBy } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import { updateHistory } from '~/lib/utils/url_utility'; import { sprintf, __, n__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import { ListType } from '../constants'; -import eventHub from '../eventhub'; import BoardBlockedIcon from './board_blocked_icon.vue'; import IssueDueDate from './issue_due_date.vue'; import IssueTimeEstimate from './issue_time_estimate.vue'; @@ -176,18 +174,10 @@ export default { ) ); }, - filterByLabel(label) { - if (!this.updateFilters) return; + labelTarget(label) { const filterPath = window.location.search ? `${window.location.search}&` : '?'; - const filter = `label_name[]=${encodeURIComponent(label.title)}`; - - if (!filterPath.includes(filter)) { - updateHistory({ - url: `${filterPath}${filter}`, - }); - this.performSearch(); - eventHub.$emit('updateTokens'); - } + const value = encodeURIComponent(label.title); + return `${filterPath}label_name[]=${value}`; }, showScopedLabel(label) { return this.scopedLabelsAvailable && isScopedLabel(label); @@ -242,7 +232,7 @@ export default { :description="label.description" size="sm" :scoped="showScopedLabel(label)" - @click="filterByLabel(label)" + :target="labelTarget(label)" /> </template> </div> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 156029b62b0..0320b4d925e 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -184,29 +184,15 @@ export default { :issuable-type="issuableType" data-testid="sidebar-milestones" /> - <template v-if="!glFeatures.iterationCadences"> - <sidebar-dropdown-widget - v-if="iterationFeatureAvailable && !isIncidentSidebar" - :iid="activeBoardItem.iid" - issuable-attribute="iteration" - :workspace-path="projectPathForActiveIssue" - :attr-workspace-path="groupPathForActiveIssue" - :issuable-type="issuableType" - class="gl-mt-5" - data-testid="iteration-edit" - /> - </template> - <template v-else> - <iteration-sidebar-dropdown-widget - v-if="iterationFeatureAvailable && !isIncidentSidebar" - :iid="activeBoardItem.iid" - :workspace-path="projectPathForActiveIssue" - :attr-workspace-path="groupPathForActiveIssue" - :issuable-type="issuableType" - class="gl-mt-5" - data-testid="iteration-edit" - /> - </template> + <iteration-sidebar-dropdown-widget + v-if="iterationFeatureAvailable && !isIncidentSidebar" + :iid="activeBoardItem.iid" + :workspace-path="projectPathForActiveIssue" + :attr-workspace-path="groupPathForActiveIssue" + :issuable-type="issuableType" + class="gl-mt-5" + data-testid="iteration-edit" + /> </div> <board-sidebar-time-tracker /> <sidebar-date-widget diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 2599d1c80b8..45192b5304a 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -1,5 +1,5 @@ <script> -import { pickBy, isEmpty } from 'lodash'; +import { pickBy, isEmpty, mapValues } from 'lodash'; import { mapActions } from 'vuex'; import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; @@ -251,22 +251,36 @@ export default { ); } - return { - ...notParams, - author_username: authorUsername, - 'label_name[]': labelName, - assignee_username: assigneeUsername, - assignee_id: assigneeId, - milestone_title: milestoneTitle, - iteration_id: iterationId, - search, - types, - weight, - epic_id: isGid(epicId) ? getIdFromGraphQLId(epicId) : epicId, - my_reaction_emoji: myReactionEmoji, - release_tag: releaseTag, - confidential, - }; + return mapValues( + { + ...notParams, + author_username: authorUsername, + 'label_name[]': labelName, + assignee_username: assigneeUsername, + assignee_id: assigneeId, + milestone_title: milestoneTitle, + iteration_id: iterationId, + search, + types, + weight, + epic_id: isGid(epicId) ? getIdFromGraphQLId(epicId) : epicId, + my_reaction_emoji: myReactionEmoji, + release_tag: releaseTag, + confidential, + }, + (value) => { + if (value || value === false) { + // note: need to check array for labels. + if (Array.isArray(value)) { + return value.map((valueItem) => encodeURIComponent(valueItem)); + } + + return encodeURIComponent(value); + } + + return value; + }, + ); }, }, created() { diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 6ad57fd8985..cc048e2af1a 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -98,9 +98,6 @@ export default { return this.$options.i18n[this.currentPage].btnText; }, buttonKind() { - if (this.isNewForm) { - return 'success'; - } if (this.isDeleteForm) { return 'danger'; } diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index e4c3c3206a8..1024be61359 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -60,6 +60,9 @@ export default { filters: this.filterParams, }; }, + skip() { + return this.isEpicBoard; + }, }, }, computed: { diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 84c9191975e..8db366e4995 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -25,7 +25,7 @@ export default { }, computed: { ...mapState(['selectedProject', 'fullPath']), - ...mapGetters(['isGroupBoard']), + ...mapGetters(['isGroupBoard', 'getBoardItemsByList']), formEventPrefix() { return toggleFormEventPrefix.issue; }, @@ -42,6 +42,7 @@ export default { const labels = this.list.label ? [this.list.label] : []; const assignees = this.list.assignee ? [this.list.assignee] : []; const milestone = getMilestone(this.list); + const firstItemId = this.getBoardItemsByList(this.list.id)[0]?.id; return this.addListNewIssue({ list: this.list, @@ -51,6 +52,7 @@ export default { assigneeIds: assignees?.map((a) => a?.id), milestoneId: milestone?.id, projectPath: this.projectPath, + moveAfterId: firstItemId, }, }).then(() => { this.cancel(); diff --git a/app/assets/javascripts/boards/components/board_new_item.vue b/app/assets/javascripts/boards/components/board_new_item.vue index 44574de17d7..600917683cd 100644 --- a/app/assets/javascripts/boards/components/board_new_item.vue +++ b/app/assets/javascripts/boards/components/board_new_item.vue @@ -43,6 +43,12 @@ export default { // eslint-disable-next-line @gitlab/require-i18n-strings return `${this.list.id}-title`; }, + isIssueTitleEmpty() { + return this.title.trim() === ''; + }, + isCreatingIssueDisabled() { + return this.isIssueTitleEmpty || this.disableSubmit; + }, }, methods: { handleFormCancel() { @@ -54,7 +60,7 @@ export default { eventHub.$emit(`scroll-board-list-${this.list.id}`); this.$emit('form-submit', { - title, + title: title.trim(), list, }); }, @@ -69,7 +75,7 @@ export default { <label :for="inputFieldId" class="gl-font-weight-bold">{{ __('Title') }}</label> <gl-form-input :id="inputFieldId" - v-model.trim="title" + v-model="title" :autofocus="true" autocomplete="off" type="text" @@ -78,7 +84,8 @@ export default { <slot></slot> <div class="gl-clearfix gl-mt-4"> <gl-button - :disabled="!title || disableSubmit" + data-testid="create-button" + :disabled="isCreatingIssueDisabled" class="gl-float-left js-no-auto-disable" variant="confirm" type="submit" diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 6b7c08d05a5..24071c6f0b4 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 { GlButton, GlDrawer, GlLabel } from '@gitlab/ui'; +import { GlButton, GlDrawer, GlLabel, GlModal, GlModalDirective } from '@gitlab/ui'; import { MountingPortal } from 'portal-vue'; import { mapActions, mapState, mapGetters } from 'vuex'; import { LIST, ListType, ListTypeTitles } from '~/boards/constants'; @@ -11,8 +11,14 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { listSettingsText: __('List settings'), + i18n: { + modalAction: __('Remove list'), + modalCopy: __('Are you sure you want to remove this list?'), + modalCancel: __('Cancel'), + }, components: { GlButton, + GlModal, GlDrawer, GlLabel, MountingPortal, @@ -21,6 +27,9 @@ export default { BoardSettingsListTypes: () => import('ee_component/boards/components/board_settings_list_types.vue'), }, + directives: { + GlModal: GlModalDirective, + }, mixins: [glFeatureFlagMixin(), Tracking.mixin()], inject: ['canAdminList', 'scopedLabelsAvailable'], inheritAttrs: false, @@ -29,6 +38,7 @@ export default { ListType, }; }, + modalId: 'board-settings-sidebar-modal', computed: { ...mapGetters(['isSidebarOpen', 'isEpicBoard']), ...mapState(['activeId', 'sidebarType', 'boardLists']), @@ -59,16 +69,16 @@ export default { }, methods: { ...mapActions(['unsetActiveId', 'removeList']), + handleModalPrimary() { + this.deleteBoard(); + }, showScopedLabels(label) { return this.scopedLabelsAvailable && isScopedLabel(label); }, deleteBoard() { - // eslint-disable-next-line no-alert - if (window.confirm(__('Are you sure you want to remove this list?'))) { - this.track('click_button', { label: 'remove_list' }); - this.removeList(this.activeId); - this.unsetActiveId(); - } + this.track('click_button', { label: 'remove_list' }); + this.removeList(this.activeId); + this.unsetActiveId(); }, }, }; @@ -92,11 +102,10 @@ export default { <template #header> <div v-if="canAdminList && activeList.id" class="gl-mt-3"> <gl-button + v-gl-modal="$options.modalId" variant="danger" category="secondary" size="small" - data-testid="remove-list" - @click.stop="deleteBoard" >{{ __('Remove list') }} </gl-button> </div> @@ -122,5 +131,21 @@ export default { /> </template> </gl-drawer> + <gl-modal + :modal-id="$options.modalId" + :title="$options.i18n.modalAction" + size="sm" + :action-primary="{ + text: $options.i18n.modalAction, + attributes: [{ variant: 'danger' }], + }" + :action-secondary="{ + text: $options.i18n.modalCancel, + attributes: [{ variant: 'default' }], + }" + @primary="handleModalPrimary" + > + <p>{{ $options.i18n.modalCopy }}</p> + </gl-modal> </mounting-portal> </template> diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 69343cd78d8..6dbb1ea0050 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -14,8 +14,6 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import BoardForm from 'ee_else_ce/boards/components/board_form.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; import { s__ } from '~/locale'; import eventHub from '../eventhub'; @@ -23,6 +21,8 @@ import groupBoardsQuery from '../graphql/group_boards.query.graphql'; import projectBoardsQuery from '../graphql/project_boards.query.graphql'; import groupBoardQuery from '../graphql/group_board.query.graphql'; import projectBoardQuery from '../graphql/project_board.query.graphql'; +import groupRecentBoardsQuery from '../graphql/group_recent_boards.query.graphql'; +import projectRecentBoardsQuery from '../graphql/project_recent_boards.query.graphql'; const MIN_BOARDS_TO_VIEW_RECENT = 10; @@ -40,7 +40,7 @@ export default { directives: { GlModalDirective, }, - inject: ['fullPath', 'recentBoardsEndpoint'], + inject: ['fullPath'], props: { throttleDuration: { type: Number, @@ -158,6 +158,10 @@ export default { this.scrollFadeInitialized = false; this.$nextTick(this.setScrollFade); }, + recentBoards() { + this.scrollFadeInitialized = false; + this.$nextTick(this.setScrollFade); + }, }, created() { eventHub.$on('showBoardModal', this.showPage); @@ -173,11 +177,11 @@ export default { cancel() { this.showPage(''); }, - boardUpdate(data) { + boardUpdate(data, boardType) { if (!data?.[this.parentType]) { return []; } - return data[this.parentType].boards.edges.map(({ node }) => ({ + return data[this.parentType][boardType].edges.map(({ node }) => ({ id: getIdFromGraphQLId(node.id), name: node.name, })); @@ -185,6 +189,9 @@ export default { boardQuery() { return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery; }, + recentBoardsQuery() { + return this.isGroupBoard ? groupRecentBoardsQuery : projectRecentBoardsQuery; + }, loadBoards(toggleDropdown = true) { if (toggleDropdown && this.boards.length > 0) { return; @@ -196,39 +203,20 @@ export default { }, query: this.boardQuery, loadingKey: 'loadingBoards', - update: this.boardUpdate, + update: (data) => this.boardUpdate(data, 'boards'), }); this.loadRecentBoards(); }, loadRecentBoards() { - this.loadingRecentBoards = true; - // Follow up to fetch recent boards using GraphQL - // https://gitlab.com/gitlab-org/gitlab/-/issues/300985 - axios - .get(this.recentBoardsEndpoint) - .then((res) => { - this.recentBoards = res.data; - }) - .catch((err) => { - /** - * If user is unauthorized we'd still want to resolve the - * request to display all boards. - */ - if (err?.response?.status === httpStatusCodes.UNAUTHORIZED) { - this.recentBoards = []; // recent boards are empty - return; - } - throw err; - }) - .then(() => this.$nextTick()) // Wait for boards list in DOM - .then(() => { - this.setScrollFade(); - }) - .catch(() => {}) - .finally(() => { - this.loadingRecentBoards = false; - }); + this.$apollo.addSmartQuery('recentBoards', { + variables() { + return { fullPath: this.fullPath }; + }, + query: this.recentBoardsQuery, + loadingKey: 'loadingRecentBoards', + update: (data) => this.boardUpdate(data, 'recentIssueBoards'), + }); }, isScrolledUp() { const { content } = this.$refs; diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index 7fc87f9f672..6bfdbb674a2 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -157,6 +157,7 @@ export default { symbol: '%', token: MilestoneToken, unique: true, + shouldSkipSort: true, fetchMilestones: this.fetchMilestones, }, { diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js index 945a508c55d..1e54c2511b8 100644 --- a/app/assets/javascripts/boards/config_toggle.js +++ b/app/assets/javascripts/boards/config_toggle.js @@ -12,6 +12,7 @@ export default () => { // eslint-disable-next-line no-new new Vue({ el, + name: 'ConfigToggleRoot', render(h) { return h(ConfigToggle, { props: { diff --git a/app/assets/javascripts/boards/graphql.js b/app/assets/javascripts/boards/graphql.js index 64938cb42ed..95863d4d5ac 100644 --- a/app/assets/javascripts/boards/graphql.js +++ b/app/assets/javascripts/boards/graphql.js @@ -1,10 +1,5 @@ -import { IntrospectionFragmentMatcher, defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import { defaultDataIdFromObject } from '@apollo/client/core'; import createDefaultClient from '~/lib/graphql'; -import introspectionQueryResultData from '~/sidebar/fragmentTypes.json'; - -const fragmentMatcher = new IntrospectionFragmentMatcher({ - introspectionQueryResultData, -}); export const gqlClient = createDefaultClient( {}, @@ -14,8 +9,6 @@ export const gqlClient = createDefaultClient( // eslint-disable-next-line no-underscore-dangle return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object); }, - - fragmentMatcher, }, }, ); diff --git a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql index 6fe8bb799d6..9e6c26063e9 100644 --- a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql @@ -1,7 +1,12 @@ query GroupBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) { group(fullPath: $fullPath) { id - milestones(includeAncestors: true, searchTitle: $searchTerm, state: $state) { + milestones( + includeAncestors: true + searchTitle: $searchTerm + state: $state + sort: EXPIRED_LAST_DUE_DATE_ASC + ) { nodes { id title diff --git a/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql new file mode 100644 index 00000000000..827c08486b1 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql @@ -0,0 +1,14 @@ +#import "ee_else_ce/boards/graphql/board.fragment.graphql" + +query group_recent_boards($fullPath: ID!) { + group(fullPath: $fullPath) { + id + recentIssueBoards { + edges { + node { + ...BoardFragment + } + } + } + } +} diff --git a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql index d917c7e809d..02aa08f90ef 100644 --- a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql @@ -1,7 +1,12 @@ query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) { project(fullPath: $fullPath) { id - milestones(searchTitle: $searchTerm, includeAncestors: true, state: $state) { + milestones( + searchTitle: $searchTerm + includeAncestors: true + state: $state + sort: EXPIRED_LAST_DUE_DATE_ASC + ) { nodes { id title diff --git a/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql new file mode 100644 index 00000000000..4d38e9b0498 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql @@ -0,0 +1,14 @@ +#import "ee_else_ce/boards/graphql/board.fragment.graphql" + +query project_recent_boards($fullPath: ID!) { + project(fullPath: $fullPath) { + id + recentIssueBoards { + edges { + node { + ...BoardFragment + } + } + } + } +} diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index ded3bfded86..f6073f9d981 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -64,6 +64,7 @@ function mountBoardApp(el) { // eslint-disable-next-line no-new new Vue({ el, + name: 'BoardAppRoot', store, apolloProvider, provide: { @@ -121,6 +122,7 @@ export default () => { // eslint-disable-next-line no-new new Vue({ el: createColumnTriggerEl, + name: 'BoardAddNewColumnTriggerRoot', components: { BoardAddNewColumnTrigger, }, @@ -144,7 +146,6 @@ export default () => { mountMultipleBoardsSwitcher({ fullPath: $boardApp.dataset.fullPath, rootPath: $boardApp.dataset.boardsEndpoint, - recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint, allowScopedLabels: $boardApp.dataset.scopedLabels, labelsManagePath: $boardApp.dataset.labelsManagePath, }); diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js index a8ade58e316..327fb9ba8d7 100644 --- a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js +++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js @@ -18,6 +18,7 @@ export default (apolloProvider, isSignedIn, releasesFetchPath) => { return new Vue({ el, + name: 'BoardFilteredSearchRoot', provide: { initialFilterParams, isSignedIn, diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index ed32579a9c3..0bc9cfbd867 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -1,27 +1,14 @@ -import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue'; import store from '~/boards/stores'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; -import introspectionQueryResultData from '~/sidebar/fragmentTypes.json'; Vue.use(VueApollo); -const fragmentMatcher = new IntrospectionFragmentMatcher({ - introspectionQueryResultData, -}); - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - cacheConfig: { - fragmentMatcher, - }, - }, - ), + defaultClient: createDefaultClient(), }); export default (params = {}) => { @@ -29,6 +16,7 @@ export default (params = {}) => { const { dataset } = boardsSwitcherElement; return new Vue({ el: boardsSwitcherElement, + name: 'BoardsSelectorRoot', components: { BoardsSelector, }, @@ -37,7 +25,6 @@ export default (params = {}) => { provide: { fullPath: params.fullPath, rootPath: params.rootPath, - recentBoardsEndpoint: params.recentBoardsEndpoint, allowScopedLabels: params.allowScopedLabels, labelsManagePath: params.labelsManagePath, allowLabelCreate: parseBoolean(dataset.canAdminBoard), diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 48ca3239cfd..1ebfcfc331b 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -15,7 +15,6 @@ import { FilterFields, ListTypeTitles, DraggableItemTypes, - active, } from 'ee_else_ce/boards/constants'; import { formatIssueInput, @@ -210,7 +209,6 @@ export default { const variables = { fullPath, searchTerm, - state: active, }; let query; diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js index 0a230f72dcc..8f057e192dd 100644 --- a/app/assets/javascripts/boards/toggle_focus.js +++ b/app/assets/javascripts/boards/toggle_focus.js @@ -6,6 +6,7 @@ export default () => { return new Vue({ el: '#js-toggle-focus-btn', + name: 'ToggleFocusRoot', render(h) { return h(ToggleFocus, { props: { diff --git a/app/assets/javascripts/broadcast_notification.js b/app/assets/javascripts/broadcast_notification.js index 2cf2e922f68..34282c6932e 100644 --- a/app/assets/javascripts/broadcast_notification.js +++ b/app/assets/javascripts/broadcast_notification.js @@ -1,4 +1,4 @@ -import Cookies from 'js-cookie'; +import { setCookie } from '~/lib/utils/common_utils'; const handleOnDismiss = ({ currentTarget }) => { currentTarget.removeEventListener('click', handleOnDismiss); @@ -6,7 +6,7 @@ const handleOnDismiss = ({ currentTarget }) => { dataset: { id, expireDate }, } = currentTarget; - Cookies.set(`hide_broadcast_message_${id}`, true, { expires: new Date(expireDate) }); + setCookie(`hide_broadcast_message_${id}`, true, { expires: new Date(expireDate) }); const notification = document.querySelector(`.js-broadcast-notification-${id}`); notification.parentNode.removeChild(notification); diff --git a/app/assets/javascripts/captcha/apollo_captcha_link.js b/app/assets/javascripts/captcha/apollo_captcha_link.js index e49abc10b29..d63ffaf5f1a 100644 --- a/app/assets/javascripts/captcha/apollo_captcha_link.js +++ b/app/assets/javascripts/captcha/apollo_captcha_link.js @@ -1,4 +1,4 @@ -import { ApolloLink, Observable } from 'apollo-link'; +import { ApolloLink, Observable } from '@apollo/client/core'; export const apolloCaptchaLink = new ApolloLink((operation, forward) => forward(operation).flatMap((result) => { 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 e630ce71bd3..2e198c59926 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 @@ -14,8 +14,8 @@ import { GlModal, GlSprintf, } from '@gitlab/ui'; -import Cookies from 'js-cookie'; import { mapActions, mapState } from 'vuex'; +import { getCookie, setCookie } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import Tracking from '~/tracking'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -59,7 +59,7 @@ export default { mixins: [glFeatureFlagsMixin(), trackingMixin], data() { return { - isTipDismissed: Cookies.get(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', + isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', validationErrorEventProperty: '', }; }, @@ -176,7 +176,7 @@ export default { 'setVariableProtected', ]), dismissTip() { - Cookies.set(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 }); + setCookie(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 }); this.isTipDismissed = true; }, deleteVarAndClose() { 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 9c0ffab7f6b..61636b389da 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 @@ -3,6 +3,7 @@ import { GlTable, GlButton, GlModalDirective, GlIcon, GlTooltipDirective } from import { mapState, mapActions } from 'vuex'; import { s__, __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; import CiVariablePopover from './ci_variable_popover.vue'; @@ -52,10 +53,11 @@ export default { }, ], components: { - GlTable, + CiVariablePopover, GlButton, GlIcon, - CiVariablePopover, + GlTable, + TooltipOnTruncate, }, directives: { GlModalDirective, @@ -67,8 +69,8 @@ export default { valuesButtonText() { return this.valuesHidden ? __('Reveal values') : __('Hide values'); }, - tableIsNotEmpty() { - return this.variables && this.variables.length > 0; + isTableEmpty() { + return !this.variables || this.variables.length === 0; }, fields() { return this.$options.fields; @@ -103,12 +105,14 @@ export default { <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" /> </template> <template #cell(key)="{ item }"> - <div class="gl-display-flex truncated-container gl-align-items-center"> - <span - :id="`ci-variable-key-${item.id}`" - class="gl-display-inline-block gl-max-w-full gl-text-truncate" - >{{ item.key }}</span - > + <div class="gl-display-flex gl-align-items-center"> + <tooltip-on-truncate :title="item.key" truncate-target="child"> + <span + :id="`ci-variable-key-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + >{{ item.key }}</span + > + </tooltip-on-truncate> <gl-button v-gl-tooltip category="tertiary" @@ -120,7 +124,7 @@ export default { </div> </template> <template #cell(value)="{ item }"> - <div class="gl-display-flex gl-align-items-center truncated-container"> + <div class="gl-display-flex gl-align-items-center"> <span v-if="valuesHidden">*********************</span> <span v-else @@ -147,10 +151,12 @@ export default { <gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" /> </template> <template #cell(environment_scope)="{ item }"> - <div class="d-flex truncated-container"> - <span :id="`ci-variable-env-${item.id}`" class="d-inline-block mw-100 text-truncate">{{ - item.environment_scope - }}</span> + <div class="gl-display-flex"> + <span + :id="`ci-variable-env-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + >{{ item.environment_scope }}</span + > <ci-variable-popover :target="`ci-variable-env-${item.id}`" :value="item.environment_scope" @@ -160,7 +166,6 @@ export default { </template> <template #cell(actions)="{ item }"> <gl-button - ref="edit-ci-variable" v-gl-modal-directive="$options.modalId" icon="pencil" :aria-label="__('Edit')" @@ -169,17 +174,16 @@ export default { /> </template> <template #empty> - <p ref="empty-variables" class="text-center empty-variables text-plain"> + <p class="gl-text-center gl-py-6 gl-text-black-normal gl-mb-0"> {{ __('There are no variables yet.') }} </p> </template> </gl-table> <div class="ci-variable-actions gl-display-flex" - :class="{ 'justify-content-center': !tableIsNotEmpty }" + :class="{ 'gl-justify-content-center': isTableEmpty }" > <gl-button - ref="add-ci-variable" v-gl-modal-directive="$options.modalId" class="gl-mr-3" data-qa-selector="add_ci_variable_button" @@ -188,8 +192,7 @@ export default { >{{ __('Add variable') }}</gl-button > <gl-button - v-if="tableIsNotEmpty" - ref="secret-value-reveal-button" + v-if="!isTableEmpty" data-qa-selector="reveal_ci_variable_value_button" @click="toggleValues(!valuesHidden)" >{{ valuesButtonText }}</gl-button diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue index a53bba6992d..63f068a9327 100644 --- a/app/assets/javascripts/clusters/agents/components/show.vue +++ b/app/assets/javascripts/clusters/agents/components/show.vue @@ -10,7 +10,7 @@ import { } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { MAX_LIST_COUNT } from '../constants'; +import { MAX_LIST_COUNT, TOKEN_STATUS_ACTIVE } from '../constants'; import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql'; import TokenTable from './token_table.vue'; import ActivityEvents from './activity_events_list.vue'; @@ -30,6 +30,7 @@ export default { return { agentName: this.agentName, projectPath: this.projectPath, + tokenStatus: TOKEN_STATUS_ACTIVE, ...this.cursor, }; }, diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js index 315c7662755..98d4707b4de 100644 --- a/app/assets/javascripts/clusters/agents/constants.js +++ b/app/assets/javascripts/clusters/agents/constants.js @@ -36,3 +36,4 @@ export const EVENT_DETAILS = { }; export const DEFAULT_ICON = 'token'; +export const TOKEN_STATUS_ACTIVE = 'ACTIVE'; diff --git a/app/assets/javascripts/clusters/agents/graphql/provider.js b/app/assets/javascripts/clusters/agents/graphql/provider.js index 8b068fa1eee..9153c5252b3 100644 --- a/app/assets/javascripts/clusters/agents/graphql/provider.js +++ b/app/assets/javascripts/clusters/agents/graphql/provider.js @@ -1,25 +1,10 @@ -import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { vulnerabilityLocationTypes } from '~/graphql_shared/fragment_types/vulnerability_location_types'; Vue.use(VueApollo); -// We create a fragment matcher so that we can create a fragment from an interface -// Without this, Apollo throws a heuristic fragment matcher warning -const fragmentMatcher = new IntrospectionFragmentMatcher({ - introspectionQueryResultData: vulnerabilityLocationTypes, -}); - -const defaultClient = createDefaultClient( - {}, - { - cacheConfig: { - fragmentMatcher, - }, - }, -); +const defaultClient = createDefaultClient(); export default new VueApollo({ defaultClient, diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql index 3662e925261..3610662afc0 100644 --- a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql +++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql @@ -4,6 +4,7 @@ query getClusterAgent( $projectPath: ID! $agentName: String! + $tokenStatus: AgentTokenStatus! $first: Int $last: Int $afterToken: String @@ -20,7 +21,13 @@ query getClusterAgent( name } - tokens(first: $first, last: $last, before: $beforeToken, after: $afterToken) { + tokens( + status: $tokenStatus + first: $first + last: $last + before: $beforeToken + after: $afterToken + ) { count nodes { diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js index 6c7fae274f8..ba7b3edba72 100644 --- a/app/assets/javascripts/clusters/agents/index.js +++ b/app/assets/javascripts/clusters/agents/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import AgentShowPage from 'ee_else_ce/clusters/agents/components/show.vue'; import apolloProvider from './graphql/provider'; +import createRouter from './router'; export default () => { const el = document.querySelector('#js-cluster-agent-details'); @@ -9,14 +10,22 @@ export default () => { return null; } - const { activityEmptyStateImage, agentName, emptyStateSvgPath, projectPath } = el.dataset; + const { + activityEmptyStateImage, + agentName, + canAdminVulnerability, + emptyStateSvgPath, + projectPath, + } = el.dataset; return new Vue({ el, apolloProvider, + router: createRouter(), provide: { activityEmptyStateImage, agentName, + canAdminVulnerability, emptyStateSvgPath, projectPath, }, diff --git a/app/assets/javascripts/clusters/agents/router.js b/app/assets/javascripts/clusters/agents/router.js new file mode 100644 index 00000000000..162a91dc300 --- /dev/null +++ b/app/assets/javascripts/clusters/agents/router.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; + +Vue.use(VueRouter); + +// Vue Router requires a component to render if the route matches, but since we're only using it for +// querystring handling, we'll create an empty component. +const EmptyRouterComponent = { + render(createElement) { + return createElement('div'); + }, +}; + +export default () => { + // Name and path here don't really matter since we're not rendering anything if the route matches. + const routes = [{ path: '/', name: 'cluster_agents', component: EmptyRouterComponent }]; + return new VueRouter({ + mode: 'history', + base: window.location.pathname, + routes, + }); +}; diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue index 695e16b7b4b..61c4904aacf 100644 --- a/app/assets/javascripts/clusters_list/components/agent_table.vue +++ b/app/assets/javascripts/clusters_list/components/agent_table.vue @@ -1,23 +1,14 @@ <script> import { GlLink, GlTable, GlIcon, GlSprintf, GlTooltip, GlPopover } from '@gitlab/ui'; -import { s__, __ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { AGENT_STATUSES } from '../constants'; +import { AGENT_STATUSES, I18N_AGENT_TABLE } from '../constants'; import { getAgentConfigPath } from '../clusters_util'; -import AgentOptions from './agent_options.vue'; +import DeleteAgentButton from './delete_agent_button.vue'; export default { - i18n: { - nameLabel: s__('ClusterAgents|Name'), - statusLabel: s__('ClusterAgents|Connection status'), - lastContactLabel: s__('ClusterAgents|Last contact'), - configurationLabel: s__('ClusterAgents|Configuration'), - optionsLabel: __('Options'), - troubleshootingText: s__('ClusterAgents|Learn how to troubleshoot'), - neverConnectedText: s__('ClusterAgents|Never'), - }, + i18n: I18N_AGENT_TABLE, components: { GlLink, GlTable, @@ -26,13 +17,15 @@ export default { GlTooltip, GlPopover, TimeAgoTooltip, - AgentOptions, + DeleteAgentButton, }, mixins: [timeagoMixin], AGENT_STATUSES, - troubleshooting_link: helpPagePath('user/clusters/agent/index', { - anchor: 'troubleshooting', + troubleshootingLink: helpPagePath('user/clusters/agent/troubleshooting'), + versionUpdateLink: helpPagePath('user/clusters/agent/install/index', { + anchor: 'update-the-agent-version', }), + inject: ['gitlabVersion'], props: { agents: { required: true, @@ -69,30 +62,93 @@ export default { tdClass, }, { + key: 'version', + label: this.$options.i18n.versionLabel, + tdClass, + }, + { key: 'configuration', label: this.$options.i18n.configurationLabel, tdClass, }, { key: 'options', - label: this.$options.i18n.optionsLabel, + label: '', tdClass, }, ]; }, + agentsList() { + if (!this.agents.length) { + return []; + } + + return this.agents.map((agent) => { + const versions = this.getAgentVersions(agent); + return { ...agent, versions }; + }); + }, }, methods: { - getCellId(item) { + getStatusCellId(item) { return `connection-status-${item.name}`; }, + getVersionCellId(item) { + return `version-${item.name}`; + }, + getPopoverTestId(item) { + return `popover-${item.name}`; + }, getAgentConfigPath, + getAgentVersions(agent) { + const agentConnections = agent.connections?.nodes || []; + + const agentVersions = agentConnections.map((agentConnection) => + agentConnection.metadata.version.replace('v', ''), + ); + + const uniqueAgentVersions = [...new Set(agentVersions)]; + + return uniqueAgentVersions.sort((a, b) => a.localeCompare(b)); + }, + getAgentVersionString(agent) { + return agent.versions[0] || ''; + }, + isVersionMismatch(agent) { + return agent.versions.length > 1; + }, + isVersionOutdated(agent) { + if (!agent.versions.length) return false; + + const [agentMajorVersion, agentMinorVersion] = this.getAgentVersionString(agent).split('.'); + const [gitlabMajorVersion, gitlabMinorVersion] = this.gitlabVersion.split('.'); + + const majorVersionMismatch = agentMajorVersion !== gitlabMajorVersion; + + // We should warn user if their current GitLab and agent versions are more than 1 minor version apart: + const minorVersionMismatch = Math.abs(agentMinorVersion - gitlabMinorVersion) > 1; + + return majorVersionMismatch || minorVersionMismatch; + }, + + getVersionPopoverTitle(agent) { + if (this.isVersionMismatch(agent) && this.isVersionOutdated(agent)) { + return this.$options.i18n.versionMismatchOutdatedTitle; + } else if (this.isVersionMismatch(agent)) { + return this.$options.i18n.versionMismatchTitle; + } else if (this.isVersionOutdated(agent)) { + return this.$options.i18n.versionOutdatedTitle; + } + + return null; + }, }, }; </script> <template> <gl-table - :items="agents" + :items="agentsList" :fields="fields" stacked="md" head-variant="white" @@ -107,19 +163,23 @@ export default { </template> <template #cell(status)="{ item }"> - <span :id="getCellId(item)" class="gl-md-pr-5" data-testid="cluster-agent-connection-status"> + <span + :id="getStatusCellId(item)" + class="gl-md-pr-5" + data-testid="cluster-agent-connection-status" + > <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3"> <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="12" /></span >{{ $options.AGENT_STATUSES[item.status].name }} </span> - <gl-tooltip v-if="item.status === 'active'" :target="getCellId(item)" placement="right"> + <gl-tooltip v-if="item.status === 'active'" :target="getStatusCellId(item)" placement="right"> <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title" ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template> </gl-sprintf> </gl-tooltip> <gl-popover v-else - :target="getCellId(item)" + :target="getStatusCellId(item)" :title="$options.AGENT_STATUSES[item.status].tooltip.title" placement="right" container="viewport" @@ -130,7 +190,7 @@ export default { > </p> <p class="gl-mb-0"> - <gl-link :href="$options.troubleshooting_link" target="_blank" class="gl-font-sm"> + <gl-link :href="$options.troubleshootingLink" target="_blank" class="gl-font-sm"> {{ $options.i18n.troubleshootingText }}</gl-link > </p> @@ -144,6 +204,52 @@ export default { </span> </template> + <template #cell(version)="{ item }"> + <span :id="getVersionCellId(item)" data-testid="cluster-agent-version"> + {{ getAgentVersionString(item) }} + + <gl-icon + v-if="isVersionMismatch(item) || isVersionOutdated(item)" + name="warning" + class="gl-text-orange-500 gl-ml-2" + /> + </span> + + <gl-popover + v-if="isVersionMismatch(item) || isVersionOutdated(item)" + :target="getVersionCellId(item)" + :title="getVersionPopoverTitle(item)" + :data-testid="getPopoverTestId(item)" + placement="right" + container="viewport" + > + <div v-if="isVersionMismatch(item) && isVersionOutdated(item)"> + <p>{{ $options.i18n.versionMismatchText }}</p> + + <p class="gl-mb-0"> + <gl-sprintf :message="$options.i18n.versionOutdatedText"> + <template #version>{{ gitlabVersion }}</template> + </gl-sprintf> + <gl-link :href="$options.versionUpdateLink" class="gl-font-sm"> + {{ $options.i18n.viewDocsText }}</gl-link + > + </p> + </div> + <p v-else-if="isVersionMismatch(item)" class="gl-mb-0"> + {{ $options.i18n.versionMismatchText }} + </p> + + <p v-else-if="isVersionOutdated(item)" class="gl-mb-0"> + <gl-sprintf :message="$options.i18n.versionOutdatedText"> + <template #version>{{ gitlabVersion }}</template> + </gl-sprintf> + <gl-link :href="$options.versionUpdateLink" class="gl-font-sm"> + {{ $options.i18n.viewDocsText }}</gl-link + > + </p> + </gl-popover> + </template> + <template #cell(configuration)="{ item }"> <span data-testid="cluster-agent-configuration-link"> <gl-link v-if="item.configFolder" :href="item.configFolder.webPath"> @@ -155,7 +261,7 @@ export default { </template> <template #cell(options)="{ item }"> - <agent-options + <delete-agent-button :agent="item" :default-branch-name="defaultBranchName" :max-agents="maxAgents" diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue index 4fc421e7c31..bf096f53e9d 100644 --- a/app/assets/javascripts/clusters_list/components/agents.vue +++ b/app/assets/javascripts/clusters_list/components/agents.vue @@ -1,11 +1,29 @@ <script> -import { GlAlert, GlKeysetPagination, GlLoadingIcon } from '@gitlab/ui'; -import { MAX_LIST_COUNT, ACTIVE_CONNECTION_TIME } from '../constants'; +import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlBanner } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import { + MAX_LIST_COUNT, + ACTIVE_CONNECTION_TIME, + AGENT_FEEDBACK_ISSUE, + AGENT_FEEDBACK_KEY, +} from '../constants'; import getAgentsQuery from '../graphql/queries/get_agents.query.graphql'; import AgentEmptyState from './agent_empty_state.vue'; import AgentTable from './agent_table.vue'; export default { + i18n: { + feedbackBannerTitle: s__('ClusterAgents|Tell us what you think'), + feedbackBannerText: s__( + 'ClusterAgents|We would love to learn more about your experience with the GitLab Agent.', + ), + feedbackBannerButton: s__('ClusterAgents|Give feedback'), + error: s__('ClusterAgents|An error occurred while loading your Agents'), + }, + AGENT_FEEDBACK_ISSUE, + AGENT_FEEDBACK_KEY, apollo: { agents: { query: getAgentsQuery, @@ -31,7 +49,10 @@ export default { GlAlert, GlKeysetPagination, GlLoadingIcon, + GlBanner, + LocalStorageSync, }, + mixins: [glFeatureFlagMixin()], inject: ['projectPath'], props: { defaultBranchName: { @@ -57,6 +78,7 @@ export default { last: null, }, folderList: {}, + feedbackBannerDismissed: false, }; }, computed: { @@ -86,6 +108,12 @@ export default { treePageInfo() { return this.agents?.project?.repository?.tree?.trees?.pageInfo || {}; }, + feedbackBannerEnabled() { + return this.glFeatures.showGitlabAgentFeedback; + }, + feedbackBannerClasses() { + return this.isChildComponent ? 'gl-my-2' : 'gl-mb-4'; + }, }, methods: { reloadAgents() { @@ -142,6 +170,9 @@ export default { const count = this.agents?.project?.clusterAgents?.count; this.$emit('onAgentsLoad', count); }, + handleBannerClose() { + this.feedbackBannerDismissed = true; + }, }, }; </script> @@ -151,6 +182,24 @@ export default { <section v-else-if="agentList"> <div v-if="agentList.length"> + <local-storage-sync + v-if="feedbackBannerEnabled" + v-model="feedbackBannerDismissed" + :storage-key="$options.AGENT_FEEDBACK_KEY" + > + <gl-banner + v-if="!feedbackBannerDismissed" + variant="introduction" + :class="feedbackBannerClasses" + :title="$options.i18n.feedbackBannerTitle" + :button-text="$options.i18n.feedbackBannerButton" + :button-link="$options.AGENT_FEEDBACK_ISSUE" + @close="handleBannerClose" + > + <p>{{ $options.i18n.feedbackBannerText }}</p> + </gl-banner> + </local-storage-sync> + <agent-table :agents="agentList" :default-branch-name="defaultBranchName" @@ -166,6 +215,6 @@ export default { </section> <gl-alert v-else variant="danger" :dismissible="false"> - {{ s__('ClusterAgents|An error occurred while loading your GitLab Agents') }} + {{ $options.i18n.error }} </gl-alert> </template> diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 9c330045596..7fb3aa3ff7e 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -57,7 +57,7 @@ export default { 'totalClusters', ]), contentAlignClasses() { - return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start'; + return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-md-justify-content-start'; }, currentPage: { get() { diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue index 25f67462223..5b8dc74b84f 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue @@ -1,5 +1,13 @@ <script> -import { GlDropdown, GlDropdownItem, GlModalDirective } from '@gitlab/ui'; +import { + GlDropdown, + GlDropdownItem, + GlModalDirective, + GlTooltipDirective, + GlDropdownDivider, + GlDropdownSectionHeader, +} from '@gitlab/ui'; + import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '../constants'; export default { @@ -8,11 +16,20 @@ export default { components: { GlDropdown, GlDropdownItem, + GlDropdownDivider, + GlDropdownSectionHeader, }, directives: { GlModalDirective, + GlTooltip: GlTooltipDirective, + }, + inject: ['newClusterPath', 'addClusterPath', 'canAddCluster'], + computed: { + tooltip() { + const { connectWithAgent, dropdownDisabledHint } = this.$options.i18n; + return this.canAddCluster ? connectWithAgent : dropdownDisabledHint; + }, }, - inject: ['newClusterPath', 'addClusterPath'], }; </script> @@ -20,22 +37,27 @@ export default { <div class="nav-controls gl-ml-auto"> <gl-dropdown ref="dropdown" - v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" + v-gl-modal-directive="canAddCluster && $options.INSTALL_AGENT_MODAL_ID" + v-gl-tooltip="tooltip" category="primary" variant="confirm" :text="$options.i18n.actionsButton" + :disabled="!canAddCluster" split right > - <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop> - {{ $options.i18n.createNewCluster }} - </gl-dropdown-item> + <gl-dropdown-section-header>{{ $options.i18n.agent }}</gl-dropdown-section-header> <gl-dropdown-item v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" data-testid="connect-new-agent-link" > {{ $options.i18n.connectWithAgent }} </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-section-header>{{ $options.i18n.certificate }}</gl-dropdown-section-header> + <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop> + {{ $options.i18n.createNewCluster }} + </gl-dropdown-item> <gl-dropdown-item :href="addClusterPath" data-testid="connect-cluster-link" @click.stop> {{ $options.i18n.connectExistingCluster }} </gl-dropdown-item> diff --git a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue index 0e312d21e4e..b730c0adfa2 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue @@ -8,6 +8,7 @@ import { GlBadge, GlLoadingIcon, GlModalDirective, + GlTooltipDirective, } from '@gitlab/ui'; import { mapState } from 'vuex'; import { @@ -33,6 +34,7 @@ export default { }, directives: { GlModalDirective, + GlTooltip: GlTooltipDirective, }, MAX_CLUSTERS_LIST, INSTALL_AGENT_MODAL_ID, @@ -40,7 +42,7 @@ export default { agent: AGENT_CARD_INFO, certificate: CERTIFICATE_BASED_CARD_INFO, }, - inject: ['addClusterPath'], + inject: ['addClusterPath', 'canAddCluster'], props: { defaultBranchName: { default: '.noBranch', @@ -91,6 +93,14 @@ export default { return cardTitle; }, + installAgentTooltip() { + return this.canAddCluster ? '' : this.$options.i18n.agent.installAgentDisabledHint; + }, + connectExistingClusterTooltip() { + return this.canAddCluster + ? '' + : this.$options.i18n.certificate.connectExistingClusterDisabledHint; + }, }, methods: { cardFooterNumber(number) { @@ -113,7 +123,7 @@ export default { <div v-show="!isLoading" data-testid="clusters-cards-container"> <gl-card header-class="gl-bg-white gl-display-flex gl-align-items-center gl-justify-content-space-between gl-py-4" - body-class="gl-pb-0" + body-class="gl-pb-0 cluster-card-item" footer-class="gl-text-right" > <template #header> @@ -166,20 +176,29 @@ export default { ><gl-sprintf :message="$options.i18n.agent.footerText" ><template #number>{{ cardFooterNumber(totalAgents) }}</template></gl-sprintf ></gl-link - ><gl-button - v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" - class="gl-ml-4" - category="secondary" - variant="confirm" - >{{ $options.i18n.agent.actionText }}</gl-button > + <div + v-gl-tooltip="installAgentTooltip" + class="gl-display-inline-block" + tabindex="-1" + data-testid="install-agent-button-tooltip" + > + <gl-button + v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" + class="gl-ml-4" + category="secondary" + variant="confirm" + :disabled="!canAddCluster" + >{{ $options.i18n.agent.actionText }}</gl-button + > + </div> </template> </gl-card> <gl-card class="gl-mt-6" header-class="gl-bg-white gl-display-flex gl-align-items-center gl-justify-content-space-between" - body-class="gl-pb-0" + body-class="gl-pb-0 cluster-card-item" footer-class="gl-text-right" > <template #header> @@ -206,14 +225,23 @@ export default { ><gl-sprintf :message="$options.i18n.certificate.footerText" ><template #number>{{ cardFooterNumber(totalClusters) }}</template></gl-sprintf ></gl-link - ><gl-button - category="secondary" - data-qa-selector="connect_existing_cluster_button" - variant="confirm" - class="gl-ml-4" - :href="addClusterPath" - >{{ $options.i18n.certificate.actionText }}</gl-button > + <div + v-gl-tooltip="connectExistingClusterTooltip" + class="gl-display-inline-block" + tabindex="-1" + data-testid="connect-existing-cluster-button-tooltip" + > + <gl-button + category="secondary" + data-qa-selector="connect_existing_cluster_button" + variant="confirm" + class="gl-ml-4" + :href="addClusterPath" + :disabled="!canAddCluster" + >{{ $options.i18n.certificate.actionText }}</gl-button + > + </div> </template> </gl-card> </div> diff --git a/app/assets/javascripts/clusters_list/components/agent_options.vue b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue index a364122ba56..6588d304d5c 100644 --- a/app/assets/javascripts/clusters_list/components/agent_options.vue +++ b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue @@ -1,36 +1,23 @@ <script> import { - GlDropdown, - GlDropdownItem, + GlButton, GlModal, GlModalDirective, GlSprintf, GlFormGroup, GlFormInput, + GlTooltipDirective, } from '@gitlab/ui'; -import { s__, __, sprintf } from '~/locale'; -import { DELETE_AGENT_MODAL_ID } from '../constants'; +import { sprintf } from '~/locale'; +import { DELETE_AGENT_BUTTON, DELETE_AGENT_MODAL_ID } from '../constants'; import deleteAgent from '../graphql/mutations/delete_agent.mutation.graphql'; import getAgentsQuery from '../graphql/queries/get_agents.query.graphql'; import { removeAgentFromStore } from '../graphql/cache_update'; export default { - i18n: { - dropdownText: __('More options'), - deleteButton: s__('ClusterAgents|Delete agent'), - modalTitle: __('Are you sure?'), - modalBody: s__( - 'ClusterAgents|Are you sure you want to delete this agent? You cannot undo this.', - ), - modalInputLabel: s__('ClusterAgents|To delete the agent, type %{name} to confirm:'), - modalAction: s__('ClusterAgents|Delete'), - modalCancel: __('Cancel'), - successMessage: s__('ClusterAgents|%{name} successfully deleted'), - defaultError: __('An error occurred. Please try again.'), - }, + i18n: DELETE_AGENT_BUTTON, components: { - GlDropdown, - GlDropdownItem, + GlButton, GlModal, GlSprintf, GlFormGroup, @@ -38,8 +25,9 @@ export default { }, directives: { GlModalDirective, + GlTooltip: GlTooltipDirective, }, - inject: ['projectPath'], + inject: ['projectPath', 'canAdminCluster'], props: { agent: { required: true, @@ -66,6 +54,13 @@ export default { }; }, computed: { + deleteButtonDisabled() { + return this.loading || !this.canAdminCluster; + }, + deleteButtonTooltip() { + const { deleteButton, disabledHint } = this.$options.i18n; + return this.deleteButtonDisabled ? disabledHint : deleteButton; + }, getAgentsQueryVariables() { return { defaultBranchName: this.defaultBranchName, @@ -159,19 +154,22 @@ export default { <template> <div> - <gl-dropdown - icon="ellipsis_v" - right - :disabled="loading" - :text="$options.i18n.dropdownText" - text-sr-only - category="tertiary" - no-caret + <div + v-gl-tooltip="deleteButtonTooltip" + class="gl-display-inline-block" + tabindex="-1" + data-testid="delete-agent-button-tooltip" > - <gl-dropdown-item v-gl-modal-directive="modalId"> - {{ $options.i18n.deleteButton }} - </gl-dropdown-item> - </gl-dropdown> + <gl-button + ref="deleteAgentButton" + v-gl-modal-directive="modalId" + icon="remove" + category="secondary" + variant="danger" + :disabled="deleteButtonDisabled" + :aria-label="$options.i18n.deleteButton" + /> + </div> <gl-modal ref="modal" diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue index 5eef76252bd..8fc0a66cd7e 100644 --- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue +++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue @@ -111,6 +111,9 @@ export default { canCancel() { return !this.registered && !this.registering && this.isAgentRegistrationModal; }, + canRegister() { + return !this.registered && this.isAgentRegistrationModal; + }, agentRegistrationCommand() { return generateAgentRegistrationCommand(this.agentToken, this.kasAddress); }, @@ -142,6 +145,9 @@ export default { isAgentRegistrationModal() { return this.modalType === MODAL_TYPE_REGISTER; }, + isKasEnabledInEmptyStateModal() { + return this.isEmptyStateModal && !this.kasDisabled; + }, }, methods: { setAgentName(name) { @@ -350,18 +356,18 @@ export default { <img :alt="i18n.altText" :src="emptyStateImage" height="100" /> </div> - <p> - <gl-sprintf :message="i18n.modalBody"> + <p v-if="kasDisabled"> + <gl-sprintf :message="i18n.enableKasText"> <template #link="{ content }"> - <gl-link :href="$options.installAgentPath"> {{ content }}</gl-link> + <gl-link :href="$options.enableKasPath">{{ content }}</gl-link> </template> </gl-sprintf> </p> - <p v-if="kasDisabled"> - <gl-sprintf :message="i18n.enableKasText"> + <p v-else> + <gl-sprintf :message="i18n.modalBody"> <template #link="{ content }"> - <gl-link :href="$options.enableKasPath"> {{ content }}</gl-link> + <gl-link :href="$options.installAgentPath">{{ content }}</gl-link> </template> </gl-sprintf> </p> @@ -380,7 +386,16 @@ export default { </gl-button> <gl-button - v-else-if="isAgentRegistrationModal" + v-if="canCancel" + :data-track-action="$options.EVENT_ACTIONS_CLICK" + :data-track-label="$options.EVENT_LABEL_MODAL" + data-track-property="cancel" + @click="closeModal" + >{{ i18n.cancel }} + </gl-button> + + <gl-button + v-if="canRegister" :disabled="!nextButtonDisabled" variant="confirm" category="primary" @@ -392,32 +407,21 @@ export default { </gl-button> <gl-button - v-if="canCancel" + v-if="isEmptyStateModal" :data-track-action="$options.EVENT_ACTIONS_CLICK" :data-track-label="$options.EVENT_LABEL_MODAL" - data-track-property="cancel" + data-track-property="done" @click="closeModal" - >{{ i18n.cancel }} + >{{ i18n.done }} </gl-button> <gl-button - v-if="isEmptyStateModal" + v-if="isKasEnabledInEmptyStateModal" :href="repositoryPath" variant="confirm" - category="secondary" - data-testid="agent-secondary-button" - >{{ i18n.secondaryButton }} - </gl-button> - - <gl-button - v-if="isEmptyStateModal" - variant="confirm" category="primary" - :data-track-action="$options.EVENT_ACTIONS_CLICK" - :data-track-label="$options.EVENT_LABEL_MODAL" - data-track-property="done" - @click="closeModal" - >{{ i18n.done }} + data-testid="agent-primary-button" + >{{ i18n.primaryButton }} </gl-button> </template> </gl-modal> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 380a5d0aada..5cf6fd050a1 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -64,6 +64,27 @@ export const STATUSES = { creating: { title: __('Creating') }, }; +export const I18N_AGENT_TABLE = { + nameLabel: s__('ClusterAgents|Name'), + statusLabel: s__('ClusterAgents|Connection status'), + lastContactLabel: s__('ClusterAgents|Last contact'), + versionLabel: __('Version'), + configurationLabel: s__('ClusterAgents|Configuration'), + optionsLabel: __('Options'), + troubleshootingText: s__('ClusterAgents|Learn how to troubleshoot'), + neverConnectedText: s__('ClusterAgents|Never'), + versionMismatchTitle: s__('ClusterAgents|Agent version mismatch'), + versionMismatchText: s__( + "ClusterAgents|The Agent version do not match each other across your cluster's pods. This can happen when a new Agent version was just deployed and Kubernetes is shutting down the old pods.", + ), + versionOutdatedTitle: s__('ClusterAgents|Agent version update required'), + versionOutdatedText: s__( + 'ClusterAgents|Your Agent version is out of sync with your GitLab version (v%{version}), which might cause compatibility problems. Update the Agent installed on your cluster to the most recent version.', + ), + versionMismatchOutdatedTitle: s__('ClusterAgents|Agent version mismatch and update'), + viewDocsText: s__('ClusterAgents|How to update the Agent?'), +}; + export const I18N_AGENT_MODAL = { agent_registration: { registerAgentButton: s__('ClusterAgents|Register'), @@ -112,7 +133,7 @@ export const I18N_AGENT_MODAL = { "ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.", ), altText: s__('ClusterAgents|GitLab Agent for Kubernetes'), - secondaryButton: s__('ClusterAgents|Go to the repository files'), + primaryButton: s__('ClusterAgents|Go to the repository files'), done: __('Cancel'), }, }; @@ -176,8 +197,8 @@ export const I18N_CLUSTERS_EMPTY_STATE = { export const AGENT_CARD_INFO = { tabName: 'agent', - title: sprintf(s__('ClusterAgents|%{number} of %{total} agents')), - emptyTitle: s__('ClusterAgents|No agents'), + title: sprintf(s__('ClusterAgents|%{number} of %{total} Agents')), + emptyTitle: s__('ClusterAgents|No Agents'), tooltip: { label: s__('ClusterAgents|Recommended'), title: s__('ClusterAgents|GitLab Agent'), @@ -188,8 +209,11 @@ export const AGENT_CARD_INFO = { ), link: helpPagePath('user/clusters/agent/index'), }, - actionText: s__('ClusterAgents|Install a new agent'), + actionText: s__('ClusterAgents|Install new Agent'), footerText: sprintf(s__('ClusterAgents|View all %{number} agents')), + installAgentDisabledHint: s__( + 'ClusterAgents|Requires a Maintainer or greater role to install new agents', + ), }; export const CERTIFICATE_BASED_CARD_INFO = { @@ -201,6 +225,9 @@ export const CERTIFICATE_BASED_CARD_INFO = { actionText: s__('ClusterAgents|Connect existing cluster'), footerText: sprintf(s__('ClusterAgents|View all %{number} clusters')), badgeText: s__('ClusterAgents|Deprecated'), + connectExistingClusterDisabledHint: s__( + 'ClusterAgents|Requires a maintainer or greater role to connect existing clusters', + ), }; export const MAX_CLUSTERS_LIST = 6; @@ -226,8 +253,25 @@ export const CLUSTERS_TABS = [ export const CLUSTERS_ACTIONS = { actionsButton: s__('ClusterAgents|Actions'), createNewCluster: s__('ClusterAgents|Create a new cluster'), - connectWithAgent: s__('ClusterAgents|Connect with the Agent'), + connectWithAgent: s__('ClusterAgents|Connect with Agent'), connectExistingCluster: s__('ClusterAgents|Connect with a certificate'), + agent: s__('ClusterAgents|Agent'), + certificate: s__('ClusterAgents|Certificate'), + dropdownDisabledHint: s__( + 'ClusterAgents|Requires a Maintainer or greater role to perform these actions', + ), +}; + +export const DELETE_AGENT_BUTTON = { + deleteButton: s__('ClusterAgents|Delete agent'), + disabledHint: s__('ClusterAgents|Requires a Maintainer or greater role to delete agents'), + modalTitle: __('Are you sure?'), + modalBody: s__('ClusterAgents|Are you sure you want to delete this agent? You cannot undo this.'), + modalInputLabel: s__('ClusterAgents|To delete the agent, type %{name} to confirm:'), + modalAction: s__('ClusterAgents|Delete'), + modalCancel: __('Cancel'), + successMessage: s__('ClusterAgents|%{name} successfully deleted'), + defaultError: __('An error occurred. Please try again.'), }; export const AGENT = 'agent'; @@ -244,3 +288,6 @@ export const MODAL_TYPE_EMPTY = 'empty_state'; export const MODAL_TYPE_REGISTER = 'agent_registration'; export const DELETE_AGENT_MODAL_ID = 'delete-agent-modal-%{agentName}'; + +export const AGENT_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/342696'; +export const AGENT_FEEDBACK_KEY = 'agent_feedback_banner'; diff --git a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql index cd46dfee170..05d2525ab98 100644 --- a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql +++ b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql @@ -2,6 +2,13 @@ fragment ClusterAgentFragment on ClusterAgent { id name webPath + connections { + nodes { + metadata { + version + } + } + } tokens { nodes { id diff --git a/app/assets/javascripts/clusters_list/load_main_view.js b/app/assets/javascripts/clusters_list/load_main_view.js index 08c99b46e16..d52b1d4a64d 100644 --- a/app/assets/javascripts/clusters_list/load_main_view.js +++ b/app/assets/javascripts/clusters_list/load_main_view.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { parseBoolean } from '~/lib/utils/common_utils'; import createDefaultClient from '~/lib/graphql'; import ClustersMainView from './components/clusters_main_view.vue'; import { createStore } from './store'; @@ -24,6 +25,9 @@ export default () => { addClusterPath, emptyStateHelpText, clustersEmptyStateImage, + canAddCluster, + canAdminCluster, + gitlabVersion, } = el.dataset; return new Vue({ @@ -37,6 +41,9 @@ export default () => { addClusterPath, emptyStateHelpText, clustersEmptyStateImage, + canAddCluster: parseBoolean(canAddCluster), + canAdminCluster: parseBoolean(canAdminCluster), + gitlabVersion, }, store: createStore(el.dataset), render(createElement) { diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index d54fb7cded2..925b411e51c 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -1,8 +1,8 @@ +import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; import { MarkdownSerializer as ProseMirrorMarkdownSerializer, defaultMarkdownSerializer, -} from 'prosemirror-markdown/src/to_markdown'; -import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; +} from '~/lib/prosemirror_markdown_serializer'; import Audio from '../extensions/audio'; import Blockquote from '../extensions/blockquote'; import Bold from '../extensions/bold'; diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index 08942374120..d1a68e80608 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -1,10 +1,9 @@ import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; -import Cookies from 'js-cookie'; import { debounce } from 'lodash'; +import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; -import { parseBoolean } from '~/lib/utils/common_utils'; export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed'; @@ -59,7 +58,7 @@ export default class ContextualSidebar { if (!ContextualSidebar.isDesktopBreakpoint()) { return; } - Cookies.set('sidebar_collapsed', value, { expires: 365 * 10 }); + setCookie('sidebar_collapsed', value, { expires: 365 * 10 }); } toggleSidebarNav(show) { @@ -111,7 +110,7 @@ export default class ContextualSidebar { if (!ContextualSidebar.isDesktopBreakpoint()) { this.toggleSidebarNav(false); } else { - const collapse = parseBoolean(Cookies.get('sidebar_collapsed')); + const collapse = parseBoolean(getCookie('sidebar_collapsed')); this.toggleCollapsedSidebar(collapse, true); } diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue index bdfabb8e846..3d7a34581b3 100644 --- a/app/assets/javascripts/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/cycle_analytics/components/base.vue @@ -1,12 +1,12 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import Cookies from 'js-cookie'; import { mapActions, mapState, mapGetters } from 'vuex'; +import { getCookie, setCookie } from '~/lib/utils/common_utils'; +import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue'; import { toYmd } from '~/analytics/shared/utils'; import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; import StageTable from '~/cycle_analytics/components/stage_table.vue'; import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue'; -import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; import { __ } from '~/locale'; import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants'; @@ -35,7 +35,7 @@ export default { }, data() { return { - isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE), + isOverviewDialogDismissed: getCookie(OVERVIEW_DIALOG_COOKIE), }; }, computed: { @@ -134,7 +134,7 @@ export default { }, dismissOverviewDialog() { this.isOverviewDialogDismissed = true; - Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 }); + setCookie(OVERVIEW_DIALOG_COOKIE, '1'); }, isUserAllowed(id) { const { permissions } = this; diff --git a/app/assets/javascripts/cycle_analytics/components/metric_tile.vue b/app/assets/javascripts/cycle_analytics/components/metric_tile.vue new file mode 100644 index 00000000000..a5c20b237b3 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/metric_tile.vue @@ -0,0 +1,51 @@ +<script> +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { redirectTo } from '~/lib/utils/url_utility'; +import MetricPopover from '~/analytics/shared/components/metric_popover.vue'; + +export default { + name: 'MetricTile', + components: { + GlSingleStat, + MetricPopover, + }, + props: { + metric: { + type: Object, + required: true, + }, + }, + computed: { + decimalPlaces() { + const parsedFloat = parseFloat(this.metric.value); + return Number.isNaN(parsedFloat) || Number.isInteger(parsedFloat) ? 0 : 1; + }, + hasLinks() { + return this.metric.links?.length && this.metric.links[0].url; + }, + }, + methods: { + clickHandler({ links }) { + if (this.hasLinks) { + redirectTo(links[0].url); + } + }, + }, +}; +</script> +<template> + <div v-bind="$attrs"> + <gl-single-stat + :id="metric.identifier" + :value="`${metric.value}`" + :title="metric.label" + :unit="metric.unit || ''" + :should-animate="true" + :animation-decimal-places="decimalPlaces" + :class="{ 'gl-hover-cursor-pointer': hasLinks }" + tabindex="0" + @click="clickHandler(metric)" + /> + <metric-popover :metric="metric" :target="metric.identifier" /> + </div> +</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue index 8f7a3f99bab..ea5a1291a17 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue @@ -266,7 +266,7 @@ export default { > <span class="gl-font-lg">·</span> <span data-testid="vsa-stage-event-date"> - {{ s__('OpenedNDaysAgo|Opened') }} + {{ s__('OpenedNDaysAgo|Created') }} <gl-link class="gl-text-black-normal" :href="item.url">{{ item.createdAt }}</gl-link> diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js index 7d5822b0824..f0b2bd9dc5b 100644 --- a/app/assets/javascripts/cycle_analytics/constants.js +++ b/app/assets/javascripts/cycle_analytics/constants.js @@ -36,31 +36,6 @@ export const OVERVIEW_METRICS = { RECENT_ACTIVITY: 'RECENT_ACTIVITY', }; -export const METRICS_POPOVER_CONTENT = { - 'lead-time': { - description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'), - }, - 'cycle-time': { - description: s__( - "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.", - ), - }, - 'lead-time-for-changes': { - description: s__( - 'ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period.', - ), - }, - 'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') }, - 'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') }, - deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') }, - 'deployment-frequency': { - description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'), - }, - commits: { - description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'), - }, -}; - export const SUMMARY_METRICS_REQUEST = [ { endpoint: METRIC_TYPE_SUMMARY, name: __('recent activity'), request: getValueStreamMetrics }, ]; diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js index 9af63f5f9cc..428bb11b950 100644 --- a/app/assets/javascripts/cycle_analytics/utils.js +++ b/app/assets/javascripts/cycle_analytics/utils.js @@ -1,14 +1,5 @@ -import { hideFlash } from '~/flash'; import { parseSeconds } from '~/lib/utils/datetime_utility'; import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility'; -import { slugify } from '~/lib/utils/text_utility'; - -export const removeFlash = (type = 'alert') => { - const flashEl = document.querySelector(`.flash-${type}`); - if (flashEl) { - hideFlash(flashEl); - } -}; /** * Takes the stages and median data, combined with the selected stage, to build an @@ -80,30 +71,11 @@ export const filterStagesByHiddenStatus = (stages = [], isHidden = true) => * @typedef {Object} TransformedMetricData * @property {String} label - Title of the metric measured * @property {String} value - String representing the decimal point value, e.g '1.5' - * @property {String} key - Slugified string based on the 'title' + * @property {String} identifier - Slugified string based on the 'title' or the provided 'identifier' attribute * @property {String} description - String to display for a description * @property {String} unit - String representing the decimal point value, e.g '1.5' */ -/** - * Prepares metric data to be rendered in the metric_card component - * - * @param {MetricData[]} data - The metric data to be rendered - * @param {Object} popoverContent - Key value pair of data to display in the popover - * @returns {TransformedMetricData[]} An array of metrics ready to render in the metric_card - */ - -export const prepareTimeMetricsData = (data = [], popoverContent = {}) => - data.map(({ title: label, ...rest }) => { - const key = slugify(label); - return { - ...rest, - label, - key, - description: popoverContent[key]?.description || '', - }; - }); - const extractFeatures = (gon) => ({ cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups), }); diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 4ab3f140b61..82bbbe891e2 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -13,7 +13,6 @@ deprecated_notes_spec.js is the spec for the legacy, jQuery notes application. I import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import Autosize from 'autosize'; import $ from 'jquery'; -import Cookies from 'js-cookie'; import { escape, uniqueId } from 'lodash'; import Vue from 'vue'; import '~/lib/utils/jquery_at_who'; @@ -28,6 +27,7 @@ import { defaultAutocompleteConfig } from './gfm_auto_complete'; import GLForm from './gl_form'; import axios from './lib/utils/axios_utils'; import { + getCookie, isInViewport, getPagePath, scrollToElement, @@ -121,7 +121,7 @@ export default class Notes { } setViewType(view) { - this.view = Cookies.get('diff_view') || view; + this.view = getCookie('diff_view') || view; } addBinding() { @@ -473,7 +473,7 @@ export default class Notes { } isParallelView() { - return Cookies.get('diff_view') === 'parallel'; + return getCookie('diff_view') === 'parallel'; } /** @@ -694,7 +694,7 @@ export default class Notes { // Convert returned HTML to a jQuery object so we can modify it further const $noteEntityEl = $(noteEntity.html); const $noteAvatar = $noteEntityEl.find('.image-diff-avatar-link'); - const $targetNoteBadge = $targetNote.find('.badge'); + const $targetNoteBadge = $targetNote.find('.design-note-pin'); $noteAvatar.append($targetNoteBadge); this.revertNoteEditForm($targetNote); diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue index b058709b316..674415ec449 100644 --- a/app/assets/javascripts/design_management/components/design_overlay.vue +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -286,6 +286,7 @@ export default { " :is-inactive="isNoteInactive(note)" :is-resolved="note.resolved" + is-on-image @mousedown.stop="onNoteMousedown($event, note)" @mouseup.stop="onNoteMouseup(note)" /> diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue index 6d0ed3b08a3..81d0b6d0df4 100644 --- a/app/assets/javascripts/design_management/components/design_sidebar.vue +++ b/app/assets/javascripts/design_management/components/design_sidebar.vue @@ -1,7 +1,7 @@ <script> import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui'; -import Cookies from 'js-cookie'; -import { parseBoolean, isLoggedIn } from '~/lib/utils/common_utils'; +import { getCookie, setCookie, parseBoolean, isLoggedIn } from '~/lib/utils/common_utils'; + import { s__ } from '~/locale'; import Participants from '~/sidebar/components/participants/participants.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -53,7 +53,7 @@ export default { }, data() { return { - isResolvedCommentsPopoverHidden: parseBoolean(Cookies.get(this.$options.cookieKey)), + isResolvedCommentsPopoverHidden: parseBoolean(getCookie(this.$options.cookieKey)), discussionWithOpenForm: '', isLoggedIn: isLoggedIn(), }; @@ -96,7 +96,7 @@ export default { methods: { handleSidebarClick() { this.isResolvedCommentsPopoverHidden = true; - Cookies.set(this.$options.cookieKey, 'true', { expires: 365 * 10 }); + setCookie(this.$options.cookieKey, 'true', { expires: 365 * 10 }); this.updateActiveDiscussion(); }, updateActiveDiscussion(id) { diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js index 5cf32cb7fe3..8c44c5a5d0a 100644 --- a/app/assets/javascripts/design_management/graphql.js +++ b/app/assets/javascripts/design_management/graphql.js @@ -1,11 +1,10 @@ -import { defaultDataIdFromObject, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import { defaultDataIdFromObject } from '@apollo/client/core'; import produce from 'immer'; import { uniqueId } from 'lodash'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; -import introspectionQueryResultData from './graphql/fragmentTypes.json'; import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql'; import getDesignQuery from './graphql/queries/get_design.query.graphql'; import typeDefs from './graphql/typedefs.graphql'; @@ -13,10 +12,6 @@ import { addPendingTodoToStore } from './utils/cache_update'; import { extractTodoIdFromDeletePath, createPendingTodo } from './utils/design_management_utils'; import { CREATE_DESIGN_TODO_EXISTS_ERROR } from './utils/error_messages'; -const fragmentMatcher = new IntrospectionFragmentMatcher({ - introspectionQueryResultData, -}); - Vue.use(VueApollo); const resolvers = { @@ -85,7 +80,6 @@ const defaultClient = createDefaultClient( } return defaultDataIdFromObject(object); }, - fragmentMatcher, }, typeDefs, }, diff --git a/app/assets/javascripts/design_management/graphql/fragmentTypes.json b/app/assets/javascripts/design_management/graphql/fragmentTypes.json deleted file mode 100644 index 0953231ea4c..00000000000 --- a/app/assets/javascripts/design_management/graphql/fragmentTypes.json +++ /dev/null @@ -1 +0,0 @@ -{"__schema":{"types":[{"kind":"INTERFACE","name":"User","possibleTypes":[{"name":"UserCore"}]},{"kind":"UNION","name":"NoteableType","possibleTypes":[{"name":"Design"},{"name":"Issue"},{"name":"MergeRequest"}]}]}} diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js index 4ae76050aa5..b856ac6c627 100644 --- a/app/assets/javascripts/design_management/index.js +++ b/app/assets/javascripts/design_management/index.js @@ -24,6 +24,7 @@ export default () => { return new Vue({ el, + name: 'DesignRoot', router, apolloProvider, provide: { diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 66d06a3a1b6..5707e4d67f9 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -26,10 +26,8 @@ import { TREE_LIST_WIDTH_STORAGE_KEY, INITIAL_TREE_WIDTH, MIN_TREE_WIDTH, - MAX_TREE_WIDTH, TREE_HIDE_STATS_WIDTH, MR_TREE_SHOW_KEY, - CENTERED_LIMITED_CONTAINER_CLASSES, ALERT_OVERFLOW_HIDDEN, ALERT_MERGE_CONFLICT, ALERT_COLLAPSED_FILES, @@ -55,6 +53,7 @@ import DiffFile from './diff_file.vue'; import HiddenFilesWarning from './hidden_files_warning.vue'; import NoChanges from './no_changes.vue'; import TreeList from './tree_list.vue'; +import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync'; export default { name: 'DiffsApp', @@ -64,8 +63,7 @@ export default { DynamicScrollerItem: () => import('vendor/vue-virtual-scroller').then(({ DynamicScrollerItem }) => DynamicScrollerItem), PreRenderer: () => import('./pre_renderer.vue').then((PreRenderer) => PreRenderer), - VirtualScrollerScrollSync: () => - import('./virtual_scroller_scroll_sync').then((VSSSync) => VSSSync), + VirtualScrollerScrollSync, CompareVersions, DiffFile, NoChanges, @@ -253,13 +251,6 @@ export default { hideFileStats() { return this.treeWidth <= TREE_HIDE_STATS_WIDTH; }, - isLimitedContainer() { - if (this.glFeatures.mrChangesFluidLayout) { - return false; - } - - return !this.renderFileTree && !this.isParallelView && !this.isFluidLayout; - }, isFullChangeset() { return this.startVersion === null && this.latestDiff; }, @@ -395,8 +386,6 @@ export default { this.adjustView(); this.subscribeToEvents(); - this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES; - this.unwatchDiscussions = this.$watch( () => `${this.diffFiles.length}:${this.$store.state.notes.discussions.length}`, () => this.setDiscussions(), @@ -417,10 +406,8 @@ export default { this.unsubscribeFromEvents(); this.removeEventListeners(); - if (window.gon?.features?.diffsVirtualScrolling) { - diffsEventHub.$off('scrollToFileHash', this.scrollVirtualScrollerToFileHash); - diffsEventHub.$off('scrollToIndex', this.scrollVirtualScrollerToIndex); - } + diffsEventHub.$off('scrollToFileHash', this.scrollVirtualScrollerToFileHash); + diffsEventHub.$off('scrollToIndex', this.scrollVirtualScrollerToIndex); }, methods: { ...mapActions(['startTaskList']), @@ -533,32 +520,27 @@ export default { ); } - if ( - window.gon?.features?.diffsVirtualScrolling || - window.gon?.features?.diffSearchingUsageData - ) { - let keydownTime; - Mousetrap.bind(['mod+f', 'mod+g'], () => { - keydownTime = new Date().getTime(); - }); + let keydownTime; + Mousetrap.bind(['mod+f', 'mod+g'], () => { + keydownTime = new Date().getTime(); + }); - window.addEventListener('blur', () => { - if (keydownTime) { - const delta = new Date().getTime() - keydownTime; + window.addEventListener('blur', () => { + if (keydownTime) { + const delta = new Date().getTime() - keydownTime; - // To make sure the user is using the find function we need to wait for blur - // and max 1000ms to be sure it the search box is filtered - if (delta >= 0 && delta < 1000) { - this.disableVirtualScroller(); + // To make sure the user is using the find function we need to wait for blur + // and max 1000ms to be sure it the search box is filtered + if (delta >= 0 && delta < 1000) { + this.disableVirtualScroller(); - if (window.gon?.features?.diffSearchingUsageData) { - api.trackRedisHllUserEvent('i_code_review_user_searches_diff'); - api.trackRedisCounterEvent('diff_searches'); - } + if (window.gon?.features?.usageDataDiffSearches) { + api.trackRedisHllUserEvent('i_code_review_user_searches_diff'); + api.trackRedisCounterEvent('diff_searches'); } } - }); - } + } + }); }, removeEventListeners() { Mousetrap.unbind(keysFor(MR_PREVIOUS_FILE_IN_DIFF)); @@ -600,8 +582,6 @@ export default { this.virtualScrollCurrentIndex = -1; }, scrollVirtualScrollerToDiffNote() { - if (!window.gon?.features?.diffsVirtualScrolling) return; - const id = window?.location?.hash; if (id.startsWith('#note_')) { @@ -616,11 +596,7 @@ export default { } }, subscribeToVirtualScrollingEvents() { - if ( - window.gon?.features?.diffsVirtualScrolling && - this.shouldShow && - !this.subscribedToVirtualScrollingEvents - ) { + if (this.shouldShow && !this.subscribedToVirtualScrollingEvents) { diffsEventHub.$on('scrollToFileHash', this.scrollVirtualScrollerToFileHash); diffsEventHub.$on('scrollToIndex', this.scrollVirtualScrollerToIndex); @@ -632,7 +608,7 @@ export default { }, }, minTreeWidth: MIN_TREE_WIDTH, - maxTreeWidth: MAX_TREE_WIDTH, + maxTreeWidth: window.innerWidth / 2, howToMergeDocsPath: helpPagePath('user/project/merge_requests/reviews/index.md', { anchor: 'checkout-merge-requests-locally-through-the-head-ref', }), @@ -643,10 +619,7 @@ export default { <div v-show="shouldShow"> <div v-if="isLoading || !isTreeLoaded" class="loading"><gl-loading-icon size="lg" /></div> <div v-else id="diffs" :class="{ active: shouldShow }" class="diffs tab-pane"> - <compare-versions - :is-limited-container="isLimitedContainer" - :diff-files-count-text="numTotalFiles" - /> + <compare-versions :diff-files-count-text="numTotalFiles" /> <template v-if="!isBatchLoadingError"> <hidden-files-warning @@ -656,10 +629,7 @@ export default { :plain-diff-path="plainDiffPath" :email-patch-path="emailPatchPath" /> - <collapsed-files-warning - v-if="visibleWarning == $options.alerts.ALERT_COLLAPSED_FILES" - :limited="isLimitedContainer" - /> + <collapsed-files-warning v-if="visibleWarning == $options.alerts.ALERT_COLLAPSED_FILES" /> </template> <div @@ -669,7 +639,7 @@ export default { <div v-if="renderFileTree" :style="{ width: `${treeWidth}px` }" - class="diff-tree-list js-diff-tree-list px-3 pr-md-0" + class="diff-tree-list js-diff-tree-list gl-px-5" > <panel-resizer :size.sync="treeWidth" @@ -681,12 +651,7 @@ export default { /> <tree-list :hide-file-stats="hideFileStats" /> </div> - <div - class="col-12 col-md-auto diff-files-holder" - :class="{ - [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer, - }" - > + <div class="col-12 col-md-auto diff-files-holder"> <commit-widget v-if="commit" :commit="commit" :collapsible="false" /> <gl-alert v-if="isBatchLoadingError" diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue index 240f102e600..b7eea32e699 100644 --- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue +++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue @@ -2,7 +2,7 @@ import { GlAlert, GlButton } from '@gitlab/ui'; import { mapState } from 'vuex'; -import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants'; +import { EVT_EXPAND_ALL_FILES } from '../constants'; import eventHub from '../event_hub'; export default { @@ -11,11 +11,6 @@ export default { GlButton, }, props: { - limited: { - type: Boolean, - required: false, - default: false, - }, dismissed: { type: Boolean, required: false, @@ -29,11 +24,6 @@ export default { }, computed: { ...mapState('diffs', ['diffFiles']), - containerClasses() { - return { - [CENTERED_LIMITED_CONTAINER_CLASSES]: this.limited, - }; - }, shouldDisplay() { return !this.isDismissed && this.diffFiles.length > 1; }, @@ -53,7 +43,7 @@ export default { </script> <template> - <div v-if="shouldDisplay" data-testid="root" :class="containerClasses" class="col-12"> + <div v-if="shouldDisplay" data-testid="root" 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 e54fde72847..df7cf83b3f0 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -155,9 +155,11 @@ export default { <gl-button v-if="commit.description_html && collapsible" + v-gl-tooltip class="js-toggle-button" size="small" icon="ellipsis_h" + :title="__('Toggle commit description')" :aria-label="__('Toggle commit description')" /> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 442807587d5..2b871680d5e 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -3,7 +3,7 @@ import { GlTooltipDirective, GlIcon, GlLink, GlButtonGroup, GlButton, GlSprintf import { mapActions, mapGetters, mapState } from 'vuex'; import { __ } from '~/locale'; import { setUrlParams } from '../../lib/utils/url_utility'; -import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants'; +import { EVT_EXPAND_ALL_FILES } from '../constants'; import eventHub from '../event_hub'; import CompareDropdownLayout from './compare_dropdown_layout.vue'; import DiffStats from './diff_stats.vue'; @@ -24,11 +24,6 @@ export default { GlTooltip: GlTooltipDirective, }, props: { - isLimitedContainer: { - type: Boolean, - required: false, - default: false, - }, diffFilesCountText: { type: String, required: false, @@ -73,9 +68,6 @@ export default { return this.commit && (this.commit.next_commit_id || this.commit.prev_commit_id); }, }, - created() { - this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES; - }, methods: { ...mapActions('diffs', ['setInlineDiffViewType', 'setParallelDiffViewType', 'setShowTreeList']), expandAllFiles() { @@ -88,12 +80,7 @@ export default { <template> <div class="mr-version-controls border-top"> - <div - class="mr-version-menus-container content-block" - :class="{ - [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer, - }" - > + <div class="mr-version-menus-container content-block"> <gl-button v-if="hasChanges" v-gl-tooltip.hover diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index 5e05ec87f84..47a05ce11cc 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -1,12 +1,14 @@ <script> import { GlIcon } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; import noteableDiscussion from '../../notes/components/noteable_discussion.vue'; export default { components: { noteableDiscussion, GlIcon, + DesignNotePin, }, props: { discussions: { @@ -62,20 +64,22 @@ export default { <ul :data-discussion-id="discussion.id" class="notes"> <template v-if="shouldCollapseDiscussions"> <button - :class="{ - 'diff-notes-collapse': discussion.expanded, - 'btn-transparent badge badge-pill': !discussion.expanded, - }" + v-if="discussion.expanded" + class="diff-notes-collapse js-diff-notes-toggle" type="button" - class="js-diff-notes-toggle" :aria-label="__('Show comments')" @click="toggleDiscussion({ discussionId: discussion.id })" > - <gl-icon v-if="discussion.expanded" name="collapse" class="collapse-icon" /> - <template v-else> - {{ index + 1 }} - </template> + <gl-icon name="collapse" class="collapse-icon" /> </button> + <design-note-pin + v-else + :label="index + 1" + :is-resolved="discussion.resolved" + size="sm" + class="js-diff-notes-toggle gl-translate-x-n50" + @click="toggleDiscussion({ discussionId: discussion.id })" + /> </template> <noteable-discussion v-show="isExpanded(discussion)" @@ -87,9 +91,12 @@ export default { @noteDeleted="deleteNoteHandler" > <template v-if="renderAvatarBadge" #avatar-badge> - <span class="badge badge-pill"> - {{ index + 1 }} - </span> + <design-note-pin + :label="index + 1" + class="user-avatar" + :is-resolved="discussion.resolved" + size="sm" + /> </template> </noteable-discussion> </ul> diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index edff2e67b20..4c7b8e8f667 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -223,25 +223,31 @@ export default { <template> <div class="content js-line-expansion-content"> - <a - v-if="canExpandDown" - class="gl-mx-2 gl-cursor-pointer js-unfold-down gl-display-inline-block gl-py-4" + <button + type="button" + :disabled="!canExpandDown" + class="js-unfold-down gl-mx-2 gl-py-4 gl-cursor-pointer" @click="handleExpandLines(EXPAND_DOWN)" > <gl-icon :size="12" name="expand-down" /> <span>{{ $options.i18n.showMore }}</span> - </a> - <a class="gl-mx-2 cursor-pointer js-unfold-all" @click="handleExpandLines()"> + </button> + <button + type="button" + class="js-unfold-all gl-mx-2 gl-py-4 gl-cursor-pointer" + @click="handleExpandLines()" + > <gl-icon :size="12" name="expand" /> <span>{{ $options.i18n.showAll }}</span> - </a> - <a - v-if="canExpandUp" - class="gl-mx-2 gl-cursor-pointer js-unfold gl-display-inline-block gl-py-4" + </button> + <button + type="button" + :disabled="!canExpandUp" + class="js-unfold gl-mx-2 gl-py-4 gl-cursor-pointer" @click="handleExpandLines(EXPAND_UP)" > <gl-icon :size="12" name="expand-up" /> <span>{{ $options.i18n.showMore }}</span> - </a> + </button> </div> </template> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 238f07ac22c..3cf1f69b08c 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -3,6 +3,7 @@ import { GlTooltipDirective, GlSafeHtmlDirective, GlIcon, + GlBadge, GlButton, GlButtonGroup, GlDropdown, @@ -34,6 +35,7 @@ export default { GlIcon, FileIcon, DiffStats, + GlBadge, GlButton, GlButtonGroup, GlDropdown, @@ -207,7 +209,7 @@ export default { handler(val) { const el = this.$el.closest('.vue-recycle-scroller__item-view'); - if (this.glFeatures.diffsVirtualScrolling && el) { + if (el) { // We can't add a style with Vue because of the way the virtual // scroller library renders the diff files el.style.zIndex = val ? '1' : null; @@ -349,7 +351,9 @@ export default { {{ diffFile.a_mode }} → {{ diffFile.b_mode }} </small> - <span v-if="isUsingLfs" class="badge label label-lfs gl-mr-2"> {{ __('LFS') }} </span> + <gl-badge v-if="isUsingLfs" variant="neutral" class="gl-mr-2" data-testid="label-lfs">{{ + __('LFS') + }}</gl-badge> </div> <div diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 8562a1d44e7..333bf1b356c 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -153,21 +153,38 @@ export default { @mousedown="handleParallelLineMouseDown" > <template v-for="(line, index) in diffLines"> - <div - v-if="line.isMatchLineLeft || line.isMatchLineRight" - :key="`expand-${index}`" - class="diff-tr line_expansion match" - > - <div class="diff-td text-center gl-font-regular"> - <diff-expansion-cell - :file-hash="diffFile.file_hash" - :context-lines-path="diffFile.context_lines_path" - :line="line.left" - :is-top="index === 0" - :is-bottom="index + 1 === diffLinesLength" - /> + <template v-if="line.isMatchLineLeft || line.isMatchLineRight"> + <div :key="`expand-${index}`" class="diff-tr line_expansion match"> + <div class="diff-td text-center gl-font-regular"> + <diff-expansion-cell + :file-hash="diffFile.file_hash" + :context-lines-path="diffFile.context_lines_path" + :line="line.left" + :is-top="index === 0" + :is-bottom="index + 1 === diffLinesLength" + /> + </div> </div> - </div> + <div + v-if="line.left.rich_text" + :key="`expand-definition-${index}`" + class="diff-grid-row diff-tr line_holder match" + > + <div class="diff-grid-left diff-grid-3-col left-side"> + <div class="diff-td diff-line-num"></div> + <div v-if="inline" class="diff-td diff-line-num"></div> + <div class="diff-td line_content left-side gl-white-space-normal!"> + {{ line.left.rich_text }} + </div> + </div> + <div v-if="!inline" class="diff-grid-right diff-grid-3-col right-side"> + <div class="diff-td diff-line-num"></div> + <div class="diff-td line_content right-side gl-white-space-normal!"> + {{ line.left.rich_text }} + </div> + </div> + </div> + </template> <diff-row v-if="!line.isMatchLineLeft && !line.isMatchLineRight" :key="line.line_code" diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue index eede8e52292..8871be1f9af 100644 --- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue +++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue @@ -1,8 +1,8 @@ <script> -import { GlIcon } from '@gitlab/ui'; import { isArray } from 'lodash'; import { mapActions, mapGetters } from 'vuex'; import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff'; +import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; function calcPercent(pos, renderedSize) { return (100 * pos) / renderedSize; @@ -11,7 +11,7 @@ function calcPercent(pos, renderedSize) { export default { name: 'ImageDiffOverlay', components: { - GlIcon, + DesignNotePin, }, mixins: [imageDiffMixin], props: { @@ -36,7 +36,7 @@ export default { badgeClass: { type: String, required: false, - default: 'badge badge-pill', + default: '', }, shouldToggleDiscussion: { type: Boolean, @@ -114,30 +114,28 @@ export default { > <span class="sr-only"> {{ __('Add image comment') }} </span> </button> - <button + + <design-note-pin v-for="(discussion, index) in allDiscussions" :key="discussion.id" - :style="getPosition(discussion)" - :class="[badgeClass, { 'is-draft': discussion.isDraft }]" - :disabled="!shouldToggleDiscussion" - class="js-image-badge" - type="button" + :label="showCommentIcon ? null : toggleText(discussion, index)" + :position="getPosition(discussion)" :aria-label="__('Show comments')" + class="js-image-badge" + :class="badgeClass" + :is-draft="discussion.isDraft" + :is-resolved="discussion.resolved" + is-on-image + :disabled="!shouldToggleDiscussion" @click="clickedToggle(discussion)" - > - <gl-icon v-if="showCommentIcon" name="image-comment-dark" :size="24" /> - <template v-else> - {{ toggleText(discussion, index) }} - </template> - </button> - <button + /> + + <design-note-pin v-if="canComment && currentCommentForm" - :style="{ left: `${currentCommentForm.xPercent}%`, top: `${currentCommentForm.yPercent}%` }" - :aria-label="__('Comment form position')" - class="btn-transparent comment-indicator position-absolute" - type="button" - > - <gl-icon name="image-comment-dark" :size="24" /> - </button> + :position="{ + left: `${currentCommentForm.xPercent}%`, + top: `${currentCommentForm.yPercent}%`, + }" + /> </div> </template> diff --git a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue index 587efd6ed41..6e1e6f5c2d0 100644 --- a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue +++ b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlAlert, GlModalDirective } from '@gitlab/ui'; -import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants'; export default { components: { @@ -11,10 +10,6 @@ export default { GlModalDirective, }, props: { - limited: { - type: Boolean, - required: true, - }, mergeable: { type: Boolean, required: true, @@ -24,18 +19,11 @@ export default { required: true, }, }, - computed: { - containerClasses() { - return { - [CENTERED_LIMITED_CONTAINER_CLASSES]: this.limited, - }; - }, - }, }; </script> <template> - <div :class="containerClasses"> + <div> <gl-alert :dismissible="false" :title="__('There are merge conflicts')" diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 93961b07e2e..bbe27c0dbd6 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -29,8 +29,6 @@ export const UNFOLD_COUNT = 20; export const COUNT_OF_AVATARS_IN_GUTTER = 3; export const LENGTH_OF_AVATAR_TOOLTIP = 17; -export const LINES_TO_BE_RENDERED_DIRECTLY = 100; - export const DIFF_FILE_SYMLINK_MODE = '120000'; export const DIFF_FILE_DELETED_MODE = '0'; @@ -42,7 +40,6 @@ export const TREE_LIST_WIDTH_STORAGE_KEY = 'mr_tree_list_width'; export const INITIAL_TREE_WIDTH = 320; export const MIN_TREE_WIDTH = 240; -export const MAX_TREE_WIDTH = 400; export const TREE_HIDE_STATS_WIDTH = 260; export const OLD_LINE_KEY = 'old_line'; @@ -50,9 +47,6 @@ export const NEW_LINE_KEY = 'new_line'; export const TYPE_KEY = 'type'; export const LEFT_LINE_KEY = 'left'; -export const CENTERED_LIMITED_CONTAINER_CLASSES = - 'container-limited limit-container-width mx-lg-auto px-3'; - export const MAX_RENDERING_DIFF_LINES = 500; export const MAX_RENDERING_BULK_ROWS = 30; export const MIN_RENDERING_MS = 2; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 260ebdf2141..1691da34c6d 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -1,8 +1,7 @@ -import Cookies from 'js-cookie'; import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import { getParameterValues } from '~/lib/utils/url_utility'; +import { getCookie, parseBoolean, removeCookie } from '~/lib/utils/common_utils'; + import eventHub from '../notes/event_hub'; import diffsApp from './components/app.vue'; @@ -58,14 +57,14 @@ export default function initDiffsApp(store) { // Check for cookie and save that setting for future use. // Then delete the cookie as we are phasing it out and using the database as SSOT. // NOTE: This can/should be removed later - if (Cookies.get(DIFF_WHITESPACE_COOKIE_NAME)) { - const hideWhitespace = Cookies.get(DIFF_WHITESPACE_COOKIE_NAME); + if (getCookie(DIFF_WHITESPACE_COOKIE_NAME)) { + const hideWhitespace = getCookie(DIFF_WHITESPACE_COOKIE_NAME); this.setShowWhitespace({ url: this.endpointUpdateUser, showWhitespace: hideWhitespace !== '1', trackClick: false, }); - Cookies.remove(DIFF_WHITESPACE_COOKIE_NAME); + removeCookie(DIFF_WHITESPACE_COOKIE_NAME); } else { // This is only to set the the user preference in Vuex for use later this.setShowWhitespace({ @@ -74,11 +73,6 @@ export default function initDiffsApp(store) { trackClick: false, }); } - - const vScrollingParam = getParameterValues('virtual_scrolling')[0]; - if (vScrollingParam === 'false' || vScrollingParam === 'true') { - Cookies.set('diffs_virtual_scrolling', vScrollingParam); - } }, methods: { ...mapActions('diffs', ['setRenderTreeList', 'setShowWhitespace']), diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 692cb913a57..e967be23f42 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -1,9 +1,14 @@ -import Cookies from 'js-cookie'; import Vue from 'vue'; +import { + setCookie, + handleLocationHash, + historyPushState, + scrollToElement, +} from '~/lib/utils/common_utils'; import createFlash from '~/flash'; import { diffViewerModes } from '~/ide/constants'; import axios from '~/lib/utils/axios_utils'; -import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils'; + import httpStatusCodes from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility'; @@ -120,7 +125,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { commit(types.SET_DIFF_DATA_BATCH, { diff_files }); commit(types.SET_BATCH_LOADING_STATE, 'loaded'); - if (window.gon?.features?.diffsVirtualScrolling && !scrolledVirtualScroller) { + if (!scrolledVirtualScroller) { const index = state.diffFiles.findIndex( (f) => f.file_hash === hash || f[INLINE_DIFF_LINES_KEY].find((l) => l.line_code === hash), @@ -190,9 +195,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { commit(types.SET_BATCH_LOADING_STATE, 'error'); }); - return getBatch().then( - () => !window.gon?.features?.diffsVirtualScrolling && handleLocationHash(), - ); + return getBatch(); }; export const fetchDiffFilesMeta = ({ commit, state }) => { @@ -369,7 +372,7 @@ export const setRenderIt = ({ commit }, file) => commit(types.RENDER_FILE, file) export const setInlineDiffViewType = ({ commit }) => { commit(types.SET_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE); - Cookies.set(DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE); + setCookie(DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE); const url = mergeUrlParams({ view: INLINE_DIFF_VIEW_TYPE }, window.location.href); historyPushState(url); @@ -381,7 +384,7 @@ export const setInlineDiffViewType = ({ commit }) => { export const setParallelDiffViewType = ({ commit }) => { commit(types.SET_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE); - Cookies.set(DIFF_VIEW_COOKIE_NAME, PARALLEL_DIFF_VIEW_TYPE); + setCookie(DIFF_VIEW_COOKIE_NAME, PARALLEL_DIFF_VIEW_TYPE); const url = mergeUrlParams({ view: PARALLEL_DIFF_VIEW_TYPE }, window.location.href); historyPushState(url); @@ -524,7 +527,7 @@ export const setCurrentFileHash = ({ commit }, hash) => { commit(types.SET_CURRENT_DIFF_FILE, hash); }; -export const scrollToFile = ({ state, commit, getters }, { path, setHash = true }) => { +export const scrollToFile = ({ state, commit, getters }, { path }) => { if (!state.treeEntries[path]) return; const { fileHash } = state.treeEntries[path]; @@ -534,11 +537,9 @@ export const scrollToFile = ({ state, commit, getters }, { path, setHash = true if (getters.isVirtualScrollingEnabled) { eventHub.$emit('scrollToFileHash', fileHash); - if (setHash) { - setTimeout(() => { - window.history.replaceState(null, null, `#${fileHash}`); - }); - } + setTimeout(() => { + window.history.replaceState(null, null, `#${fileHash}`); + }); } else { document.location.hash = fileHash; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index ca85be5d829..3a85c1a9fe1 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -1,6 +1,5 @@ -import Cookies from 'js-cookie'; -import { getParameterValues } from '~/lib/utils/url_utility'; import { __, n__ } from '~/locale'; +import { getParameterValues } from '~/lib/utils/url_utility'; import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE, @@ -175,21 +174,11 @@ export function suggestionCommitMessage(state, _, rootState) { } export const isVirtualScrollingEnabled = (state) => { - const vSrollerCookie = Cookies.get('diffs_virtual_scrolling'); - - if (state.disableVirtualScroller) { + if (state.disableVirtualScroller || getParameterValues('virtual_scrolling')[0] === 'false') { return false; } - if (vSrollerCookie) { - return vSrollerCookie === 'true'; - } - - return ( - !state.viewDiffsFileByFile && - (window.gon?.features?.diffsVirtualScrolling || - getParameterValues('virtual_scrolling')[0] === 'true') - ); + return !state.viewDiffsFileByFile; }; export const isBatchLoading = (state) => state.batchLoadingState === 'loading'; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 5f66360a040..329db1fe2cf 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -1,10 +1,10 @@ -import Cookies from 'js-cookie'; +import { getCookie } from '~/lib/utils/common_utils'; import { getParameterValues } from '~/lib/utils/url_utility'; import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; const getViewTypeFromQueryString = () => getParameterValues('view')[0]; -const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME); +const viewTypeFromCookie = getCookie(DIFF_VIEW_COOKIE_NAME); const defaultViewType = INLINE_DIFF_VIEW_TYPE; export default () => ({ diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 3f1af68e37a..f2028892a5f 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -9,7 +9,6 @@ import { NEW_LINE_TYPE, OLD_LINE_TYPE, MATCH_LINE_TYPE, - LINES_TO_BE_RENDERED_DIRECTLY, INLINE_DIFF_LINES_KEY, CONFLICT_OUR, CONFLICT_THEIR, @@ -380,16 +379,9 @@ function prepareDiffFileLines(file) { return file; } -function finalizeDiffFile(file, index) { - let renderIt = Boolean(window.gon?.features?.diffsVirtualScrolling); - - if (!window.gon?.features?.diffsVirtualScrolling) { - renderIt = - index < 3 ? file[INLINE_DIFF_LINES_KEY].length < LINES_TO_BE_RENDERED_DIRECTLY : false; - } - +function finalizeDiffFile(file) { Object.assign(file, { - renderIt, + renderIt: true, isShowingFullFile: false, isLoadingFullFile: false, discussions: [], @@ -417,15 +409,13 @@ export function prepareDiffData({ diff, priorFiles = [], meta = false }) { .map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles, meta })) .map(ensureBasicDiffFileLines) .map(prepareDiffFileLines) - .map((file, index) => finalizeDiffFile(file, priorFiles.length + index)); + .map((file) => finalizeDiffFile(file)); return deduplicateFilesList([...priorFiles, ...cleanedFiles]); } export function getDiffPositionByLineCode(diffFiles) { - let lines = []; - - lines = diffFiles.reduce((acc, diffFile) => { + const lines = diffFiles.reduce((acc, diffFile) => { diffFile[INLINE_DIFF_LINES_KEY].forEach((line) => { acc.push({ file: diffFile, line }); }); diff --git a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js index 05ce617ca7c..2fba02f212b 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js @@ -20,33 +20,6 @@ export class YamlEditorExtension { } /** - * Extends the source editor with capabilities for yaml files. - * - * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance - * @param {YamlEditorExtensionOptions} setupOptions - */ - onSetup(instance, setupOptions = {}) { - const { enableComments = false, highlightPath = null, model = null } = setupOptions; - this.enableComments = enableComments; - this.highlightPath = highlightPath; - this.model = model; - - if (model) { - this.initFromModel(instance, model); - } - - instance.onDidChangeModelContent(() => instance.onUpdate()); - } - - initFromModel(instance, model) { - const doc = new Document(model); - if (this.enableComments) { - YamlEditorExtension.transformComments(doc); - } - instance.setValue(doc.toString()); - } - - /** * @private * This wraps long comments to a maximum line length of 80 chars. * @@ -164,10 +137,10 @@ export class YamlEditorExtension { if (!path) throw Error(`No path provided.`); const blob = instance.getValue(); const doc = parseDocument(blob); - const pathArray = toPath(path); + const pathArray = Array.isArray(path) ? path : toPath(path); if (!doc.getIn(pathArray)) { - throw Error(`The node ${path} could not be found inside the document.`); + return [null, null]; } const parentNode = doc.getIn(pathArray.slice(0, pathArray.length - 1)); @@ -190,6 +163,33 @@ export class YamlEditorExtension { return [startLine, endLine]; } + /** + * Extends the source editor with capabilities for yaml files. + * + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param {YamlEditorExtensionOptions} setupOptions + */ + onSetup(instance, setupOptions = {}) { + const { enableComments = false, highlightPath = null, model = null } = setupOptions; + this.enableComments = enableComments; + this.highlightPath = highlightPath; + this.model = model; + + if (model) { + this.initFromModel(instance, model); + } + + instance.onDidChangeModelContent(() => instance.onUpdate()); + } + + initFromModel(instance, model) { + const doc = new Document(model); + if (this.enableComments) { + YamlEditorExtension.transformComments(doc); + } + instance.setValue(doc.toString()); + } + setDoc(instance, doc) { if (this.enableComments) { YamlEditorExtension.transformComments(doc); @@ -202,18 +202,31 @@ export class YamlEditorExtension { } } - highlight(instance, path) { + highlight(instance, path, keepOnNotFound = false) { // IMPORTANT // removeHighlight and highlightLines both come from // SourceEditorExtension. So it has to be installed prior to this extension if (this.highlightPath === path) return; - if (!path) { + + if (!path || !path.length) { instance.removeHighlights(); - } else { - const res = YamlEditorExtension.locate(instance, path); - instance.highlightLines(res); + this.highlightPath = null; + return; } - this.highlightPath = path || null; + + const [startLine, endLine] = YamlEditorExtension.locate(instance, path); + + if (startLine === null) { + // Path could not be found. + if (!keepOnNotFound) { + instance.removeHighlights(); + this.highlightPath = null; + } + return; + } + + instance.highlightLines([startLine, endLine]); + this.highlightPath = path; } provides() { @@ -283,18 +296,23 @@ export class YamlEditorExtension { * Add a line highlight style to the node specified by the path. * * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance - * @param {string|null|false} path A path to a node of the Editor's value, + * @param {string|(string|number)[]|null|false} path A path to a node + * of the Editor's + * value, * e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all * highlights. + * @param {boolean} [keepOnNotFound=false] If the passed path cannot + * be located, keep the previous highlight state */ - highlight: (instance, path) => this.highlight(instance, path), + highlight: (instance, path, keepOnNotFound) => this.highlight(instance, path, keepOnNotFound), /** * Return the line numbers of a certain node identified by `path` within * the yaml. * * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance - * @param {string} path A path to a node, eg. `foo.bar[0]` + * @param {string|(string|number)[]} path A path to a node, eg. + * `foo.bar[0]` * @returns {number[]} Array following the schema `[firstLine, lastLine]` * (both inclusive) * diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index f0db3e5594b..4d9fe6ff851 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -765,6 +765,9 @@ "filter": { "oneOf": [ { + "type": "null" + }, + { "$ref": "#/definitions/filter_refs" }, { diff --git a/app/assets/javascripts/emoji/awards_app/index.js b/app/assets/javascripts/emoji/awards_app/index.js index 1a084d37762..0986533dcd1 100644 --- a/app/assets/javascripts/emoji/awards_app/index.js +++ b/app/assets/javascripts/emoji/awards_app/index.js @@ -12,6 +12,7 @@ export default (el) => { return new Vue({ el, + name: 'AwardsListRoot', store: createstore(), computed: { ...mapState(['currentUserId', 'canAwardEmoji', 'awards']), diff --git a/app/assets/javascripts/emoji/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js index f0340209248..f83bfe614dd 100644 --- a/app/assets/javascripts/emoji/awards_app/store/actions.js +++ b/app/assets/javascripts/emoji/awards_app/store/actions.js @@ -33,20 +33,51 @@ export const fetchAwards = async ({ commit, dispatch, state }, page = '1') => { } }; +/** + * Creates an intermediary award, used for display + * until the real award is loaded from the backend. + */ +const newOptimisticAward = (name, state) => { + const freeId = Math.min(...state.awards.map((a) => a.id), Number.MAX_SAFE_INTEGER) - 1; + return { + id: freeId, + name, + user: { + id: window.gon.current_user_id, + name: window.gon.current_user_fullname, + username: window.gon.current_username, + }, + }; +}; + export const toggleAward = async ({ commit, state }, name) => { const award = state.awards.find((a) => a.name === name && a.user.id === state.currentUserId); try { if (award) { - await axios.delete(joinPaths(gon.relative_url_root || '', `${state.path}/${award.id}`)); - commit(REMOVE_AWARD, award.id); + await axios + .delete(joinPaths(gon.relative_url_root || '', `${state.path}/${award.id}`)) + .catch((err) => { + commit(ADD_NEW_AWARD, award); + + throw err; + }); + showToast(__('Award removed')); } else { - const { data } = await axios.post(joinPaths(gon.relative_url_root || '', state.path), { - name, - }); + const optimisticAward = newOptimisticAward(name, state); + + commit(ADD_NEW_AWARD, optimisticAward); + + const { data } = await axios + .post(joinPaths(gon.relative_url_root || '', state.path), { + name, + }) + .finally(() => { + commit(REMOVE_AWARD, optimisticAward.id); + }); commit(ADD_NEW_AWARD, data); diff --git a/app/assets/javascripts/emoji/components/utils.js b/app/assets/javascripts/emoji/components/utils.js index 3465a8ae7e6..5eec0992896 100644 --- a/app/assets/javascripts/emoji/components/utils.js +++ b/app/assets/javascripts/emoji/components/utils.js @@ -1,5 +1,5 @@ -import Cookies from 'js-cookie'; import { chunk, memoize, uniq } from 'lodash'; +import { getCookie, setCookie } from '~/lib/utils/common_utils'; import { initEmojiMap, getEmojiCategoryMap } from '~/emoji'; import { EMOJIS_PER_ROW, @@ -13,7 +13,7 @@ export const generateCategoryHeight = (emojisLength) => emojisLength * EMOJI_ROW_HEIGHT + CATEGORY_ROW_HEIGHT; export const getFrequentlyUsedEmojis = () => { - const savedEmojis = Cookies.get(FREQUENTLY_USED_COOKIE_KEY); + const savedEmojis = getCookie(FREQUENTLY_USED_COOKIE_KEY); if (!savedEmojis) return null; @@ -30,13 +30,13 @@ export const getFrequentlyUsedEmojis = () => { export const addToFrequentlyUsed = (emoji) => { const frequentlyUsedEmojis = uniq( - (Cookies.get(FREQUENTLY_USED_COOKIE_KEY) || '') + (getCookie(FREQUENTLY_USED_COOKIE_KEY) || '') .split(',') .filter((e) => e) .concat(emoji), ); - Cookies.set(FREQUENTLY_USED_COOKIE_KEY, frequentlyUsedEmojis.join(','), { expires: 365 }); + setCookie(FREQUENTLY_USED_COOKIE_KEY, frequentlyUsedEmojis.join(',')); }; export const hasFrequentlyUsedEmojis = () => getFrequentlyUsedEmojis() !== null; diff --git a/app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js b/app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js new file mode 100644 index 00000000000..012cf949c96 --- /dev/null +++ b/app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js @@ -0,0 +1,3 @@ +import { initRedirectListboxBehavior } from '~/listbox/redirect_behavior'; + +initRedirectListboxBehavior(); diff --git a/app/assets/javascripts/environments/components/canary_ingress.vue b/app/assets/javascripts/environments/components/canary_ingress.vue index 02d660a91c1..30f3f9dfc75 100644 --- a/app/assets/javascripts/environments/components/canary_ingress.vue +++ b/app/assets/javascripts/environments/components/canary_ingress.vue @@ -17,6 +17,11 @@ export default { required: true, type: Object, }, + graphql: { + required: false, + type: Boolean, + default: false, + }, }, ingressOptions: Array(100 / 5 + 1) .fill(0) @@ -47,11 +52,17 @@ export default { canaryWeightId() { return uniqueId('canary-weight-'); }, + weight() { + if (this.graphql) { + return this.canaryIngress.canaryWeight; + } + return this.canaryIngress.canary_weight; + }, stableWeight() { - return (100 - this.canaryIngress.canary_weight).toString(); + return (100 - this.weight).toString(); }, canaryWeight() { - return this.canaryIngress.canary_weight.toString(); + return this.weight.toString(); }, }, methods: { diff --git a/app/assets/javascripts/environments/components/canary_update_modal.vue b/app/assets/javascripts/environments/components/canary_update_modal.vue index 8b1121c7158..fd4885a9dbd 100644 --- a/app/assets/javascripts/environments/components/canary_update_modal.vue +++ b/app/assets/javascripts/environments/components/canary_update_modal.vue @@ -71,7 +71,7 @@ export default { mutation: updateCanaryIngress, variables: { input: { - id: this.environment.global_id, + id: this.environment.global_id || this.environment.globalId, weight: this.weight, }, }, diff --git a/app/assets/javascripts/environments/components/commit.vue b/app/assets/javascripts/environments/components/commit.vue new file mode 100644 index 00000000000..54b94480685 --- /dev/null +++ b/app/assets/javascripts/environments/components/commit.vue @@ -0,0 +1,54 @@ +<script> +import { GlAvatar, GlAvatarLink, GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { escape } from 'lodash'; + +export default { + components: { + GlAvatar, + GlAvatarLink, + GlLink, + }, + directives: { + GlTooltip, + }, + props: { + commit: { + required: true, + type: Object, + }, + }, + computed: { + commitMessage() { + return this.commit?.message; + }, + commitAuthorPath() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return this.commit?.author?.path || `mailto:${escape(this.commit?.authorEmail)}`; + }, + commitAuthorAvatar() { + return this.commit?.author?.avatarUrl || this.commit?.authorGravatarUrl; + }, + commitAuthor() { + return this.commit?.author?.name || this.commit?.authorName; + }, + commitPath() { + return this.commit?.commitPath; + }, + }, +}; +</script> +<template> + <div data-testid="deployment-commit" class="gl-display-flex gl-align-items-center"> + <gl-avatar-link v-gl-tooltip :title="commitAuthor" :href="commitAuthorPath"> + <gl-avatar :size="16" :src="commitAuthorAvatar" /> + </gl-avatar-link> + <gl-link + v-gl-tooltip + :title="commitMessage" + :href="commitPath" + class="gl-ml-3 gl-str-truncated" + > + {{ commitMessage }} + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue index c642a07fd1e..8a379ebdf66 100644 --- a/app/assets/javascripts/environments/components/deploy_board.vue +++ b/app/assets/javascripts/environments/components/deploy_board.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ /** * Renders a deploy board. * @@ -17,11 +16,11 @@ import { GlTooltip, GlTooltipDirective, GlSafeHtmlDirective as SafeHtml, + GlSprintf, } from '@gitlab/ui'; import { isEmpty } from 'lodash'; -import { n__ } from '~/locale'; +import { s__, n__ } from '~/locale'; import instanceComponent from '~/vue_shared/components/deployment_instance.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { STATUS_MAP, CANARY_STATUS } from '../constants'; import CanaryIngress from './canary_ingress.vue'; @@ -32,13 +31,13 @@ export default { GlIcon, GlLoadingIcon, GlLink, + GlSprintf, GlTooltip, }, directives: { GlTooltip: GlTooltipDirective, SafeHtml, }, - mixins: [glFeatureFlagsMixin()], props: { deployBoardData: { type: Object, @@ -57,6 +56,11 @@ export default { required: false, default: '', }, + graphql: { + type: Boolean, + required: false, + default: false, + }, }, computed: { canRenderDeployBoard() { @@ -65,8 +69,15 @@ export default { canRenderEmptyState() { return this.isEmpty; }, + canaryIngress() { + if (this.graphql) { + return this.deployBoardData.canaryIngress; + } + + return this.deployBoardData.canary_ingress; + }, canRenderCanaryWeight() { - return !isEmpty(this.deployBoardData.canary_ingress); + return !isEmpty(this.canaryIngress); }, instanceCount() { const { instances } = this.deployBoardData; @@ -90,8 +101,20 @@ export default { deployBoardSvg() { return deployBoardSvg; }, + rollbackUrl() { + if (this.graphql) { + return this.deployBoardData.rollbackUrl; + } + return this.deployBoardData.rollback_url; + }, + abortUrl() { + if (this.graphql) { + return this.deployBoardData.abortUrl; + } + return this.deployBoardData.abort_url; + }, deployBoardActions() { - return this.deployBoardData.rollback_url || this.deployBoardData.abort_url; + return this.rollbackUrl || this.abortUrl; }, statuses() { // Canary is not a pod status but it needs to be in the legend. @@ -106,7 +129,17 @@ export default { changeCanaryWeight(weight) { this.$emit('changeCanaryWeight', weight); }, + podName(instance) { + if (this.graphql) { + return instance.podName; + } + + return instance.pod_name; + }, }, + emptyStateText: s__( + 'DeployBoards|To see deployment progress for your environments, make sure you are deploying to %{codeStart}$KUBE_NAMESPACE%{codeEnd} and annotating with %{codeStart}app.gitlab.com/app=$CI_PROJECT_PATH_SLUG%{codeEnd} and %{codeStart}app.gitlab.com/env=$CI_ENVIRONMENT_SLUG%{codeEnd}.', + ), }; </script> <template> @@ -152,7 +185,7 @@ export default { :key="i" :status="instance.status" :tooltip-text="instance.tooltip" - :pod-name="instance.pod_name" + :pod-name="podName(instance)" :logs-path="logsPath" :stable="instance.stable" /> @@ -163,22 +196,23 @@ export default { <canary-ingress v-if="canRenderCanaryWeight" class="deploy-board-canary-ingress" - :canary-ingress="deployBoardData.canary_ingress" + :canary-ingress="canaryIngress" + :graphql="graphql" @change="changeCanaryWeight" /> <section v-if="deployBoardActions" class="deploy-board-actions"> <gl-link - v-if="deployBoardData.rollback_url" - :href="deployBoardData.rollback_url" + v-if="rollbackUrl" + :href="rollbackUrl" class="btn" data-method="post" rel="nofollow" >{{ __('Rollback') }}</gl-link > <gl-link - v-if="deployBoardData.abort_url" - :href="deployBoardData.abort_url" + v-if="abortUrl" + :href="abortUrl" class="btn btn-danger btn-inverted" data-method="post" rel="nofollow" @@ -196,11 +230,11 @@ export default { __('Kubernetes deployment not found') }}</span> <span> - To see deployment progress for your environments, make sure you are deploying to - <code>$KUBE_NAMESPACE</code> and annotating with - <code>app.gitlab.com/app=$CI_PROJECT_PATH_SLUG</code> - and - <code>app.gitlab.com/env=$CI_ENVIRONMENT_SLUG</code>. + <gl-sprintf :message="$options.emptyStateText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> </span> </section> </div> diff --git a/app/assets/javascripts/environments/components/deploy_board_wrapper.vue b/app/assets/javascripts/environments/components/deploy_board_wrapper.vue new file mode 100644 index 00000000000..d9d77088ad3 --- /dev/null +++ b/app/assets/javascripts/environments/components/deploy_board_wrapper.vue @@ -0,0 +1,86 @@ +<script> +import { GlCollapse, GlButton } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import setEnvironmentToChangeCanaryMutation from '../graphql/mutations/set_environment_to_change_canary.mutation.graphql'; +import DeployBoard from './deploy_board.vue'; + +export default { + components: { + DeployBoard, + GlButton, + GlCollapse, + }, + props: { + rolloutStatus: { + required: true, + type: Object, + }, + environment: { + required: true, + type: Object, + }, + }, + data() { + return { visible: false }; + }, + computed: { + icon() { + return this.visible ? 'angle-down' : 'angle-right'; + }, + label() { + return this.visible ? this.$options.i18n.collapse : this.$options.i18n.expand; + }, + isLoading() { + return this.rolloutStatus.status === 'loading'; + }, + isEmpty() { + return this.rolloutStatus.status === 'not_found'; + }, + }, + methods: { + toggleCollapse() { + this.visible = !this.visible; + }, + changeCanaryWeight(weight) { + this.$apollo.mutate({ + mutation: setEnvironmentToChangeCanaryMutation, + variables: { + environment: this.environment, + weight, + }, + }); + }, + }, + i18n: { + collapse: __('Collapse'), + expand: __('Expand'), + pods: s__('DeployBoard|Kubernetes Pods'), + }, +}; +</script> +<template> + <div> + <div> + <gl-button + class="gl-mr-4 gl-min-w-fit-content" + :icon="icon" + :aria-label="label" + size="small" + category="tertiary" + @click="toggleCollapse" + /> + <span>{{ $options.i18n.pods }}</span> + </div> + <gl-collapse :visible="visible"> + <deploy-board + :deploy-board-data="rolloutStatus" + :is-loading="isLoading" + :is-empty="isEmpty" + :environment="environment" + graphql + class="gl-reset-bg!" + @changeCanaryWeight="changeCanaryWeight" + /> + </gl-collapse> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue index ef43ca6bc33..f98edb6bb7d 100644 --- a/app/assets/javascripts/environments/components/deployment.vue +++ b/app/assets/javascripts/environments/components/deployment.vue @@ -1,25 +1,240 @@ <script> +import { + GlBadge, + GlButton, + GlCollapse, + GlIcon, + GlLink, + GlTooltipDirective as GlTooltip, + GlTruncate, +} from '@gitlab/ui'; +import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; +import { __, s__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import DeploymentStatusBadge from './deployment_status_badge.vue'; +import Commit from './commit.vue'; export default { components: { + ClipboardButton, + Commit, DeploymentStatusBadge, + GlBadge, + GlButton, + GlCollapse, + GlIcon, + GlLink, + GlTruncate, + TimeAgoTooltip, + }, + directives: { + GlTooltip, }, props: { deployment: { type: Object, required: true, }, + latest: { + type: Boolean, + default: false, + required: false, + }, + }, + data() { + return { visible: false }; }, computed: { status() { return this.deployment?.status; }, + iid() { + return this.deployment?.iid; + }, + shortSha() { + return this.commit?.shortId; + }, + createdAt() { + return this.deployment?.createdAt; + }, + isMobile() { + return !GlBreakpointInstance.isDesktop(); + }, + detailsButton() { + return this.visible + ? { text: this.$options.i18n.hideDetails, icon: 'expand-up' } + : { text: this.$options.i18n.showDetails, icon: 'expand-down' }; + }, + detailsButtonClasses() { + return this.isMobile ? 'gl-sr-only' : ''; + }, + commit() { + return this.deployment?.commit; + }, + user() { + return this.deployment?.user; + }, + username() { + return `@${this.user.username}`; + }, + userPath() { + return this.user?.path; + }, + deployable() { + return this.deployment?.deployable; + }, + jobName() { + return this.deployable?.name; + }, + jobPath() { + return this.deployable?.buildPath; + }, + refLabel() { + return this.deployment?.tag ? this.$options.i18n.tag : this.$options.i18n.branch; + }, + ref() { + return this.deployment?.ref; + }, + refName() { + return this.ref?.name; + }, + refPath() { + return this.ref?.refPath; + }, + }, + methods: { + toggleCollapse() { + this.visible = !this.visible; + }, + }, + i18n: { + latestBadge: s__('Deployment|Latest Deployed'), + deploymentId: s__('Deployment|Deployment ID'), + copyButton: __('Copy commit SHA'), + commitSha: __('Commit SHA'), + showDetails: __('Show details'), + hideDetails: __('Hide details'), + triggerer: s__('Deployment|Triggerer'), + job: __('Job'), + api: __('API'), + branch: __('Branch'), + tag: __('Tag'), }, + headerClasses: [ + 'gl-display-flex', + 'gl-align-items-flex-start', + 'gl-md-align-items-center', + 'gl-justify-content-space-between', + 'gl-pr-6', + ], + headerDetailsClasses: [ + 'gl-display-flex', + 'gl-flex-direction-column', + 'gl-md-flex-direction-row', + 'gl-align-items-flex-start', + 'gl-md-align-items-center', + 'gl-font-sm', + 'gl-text-gray-700', + ], + deploymentStatusClasses: [ + 'gl-display-flex', + 'gl-gap-x-3', + 'gl-mr-0', + 'gl-md-mr-5', + 'gl-mb-3', + 'gl-md-mb-0', + ], }; </script> <template> <div> - <deployment-status-badge v-if="status" :status="status" /> + <div :class="$options.headerClasses"> + <div :class="$options.headerDetailsClasses"> + <div :class="$options.deploymentStatusClasses"> + <deployment-status-badge v-if="status" :status="status" /> + <gl-badge v-if="latest" variant="info">{{ $options.i18n.latestBadge }}</gl-badge> + </div> + <div class="gl-display-flex gl-align-items-center gl-gap-x-5"> + <div + v-if="iid" + v-gl-tooltip + class="gl-display-flex" + :title="$options.i18n.deploymentId" + :aria-label="$options.i18n.deploymentId" + > + <gl-icon ref="deployment-iid-icon" name="deployments" /> + <span class="gl-ml-2">#{{ iid }}</span> + </div> + <div + v-if="shortSha" + data-testid="deployment-commit-sha" + class="gl-font-monospace gl-display-flex gl-align-items-center" + > + <gl-icon ref="deployment-commit-icon" name="commit" class="gl-mr-2" /> + <span v-gl-tooltip :title="$options.i18n.commitSha">{{ shortSha }}</span> + <clipboard-button + :text="shortSha" + category="tertiary" + :title="$options.i18n.copyButton" + size="small" + /> + </div> + <time-ago-tooltip v-if="createdAt" :time="createdAt" class="gl-display-flex"> + <template #default="{ timeAgo }"> + <gl-icon name="calendar" /> + <span class="gl-mr-2 gl-white-space-nowrap">{{ timeAgo }}</span> + </template> + </time-ago-tooltip> + </div> + </div> + <gl-button + ref="details-toggle" + category="tertiary" + :icon="detailsButton.icon" + :button-text-classes="detailsButtonClasses" + @click="toggleCollapse" + > + {{ detailsButton.text }} + </gl-button> + </div> + <commit v-if="commit" :commit="commit" class="gl-mt-3" /> + <gl-collapse :visible="visible"> + <div + class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0" + > + <div v-if="user" class="gl-display-flex gl-flex-direction-column gl-md-max-w-15p"> + <span class="gl-text-gray-500">{{ $options.i18n.triggerer }}</span> + <gl-link :href="userPath" class="gl-font-monospace gl-mt-3"> + <gl-truncate :text="username" with-tooltip /> + </gl-link> + </div> + <div + class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0" + > + <span class="gl-text-gray-500" :class="{ 'gl-ml-3': !deployable }"> + {{ $options.i18n.job }} + </span> + <gl-link v-if="jobPath" :href="jobPath" class="gl-font-monospace gl-mt-3"> + <gl-truncate :text="jobName" with-tooltip position="middle" /> + </gl-link> + <span v-else-if="jobName" class="gl-font-monospace gl-mt-3"> + <gl-truncate :text="jobName" with-tooltip position="middle" /> + </span> + <gl-badge v-else class="gl-font-monospace gl-mt-3" variant="info"> + {{ $options.i18n.api }} + </gl-badge> + </div> + <div + v-if="ref" + class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0" + > + <span class="gl-text-gray-500">{{ refLabel }}</span> + <gl-link :href="refPath" class="gl-font-monospace gl-mt-3"> + <gl-truncate :text="refName" with-tooltip /> + </gl-link> + </div> + </div> + </gl-collapse> </div> </template> diff --git a/app/assets/javascripts/environments/components/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue index 977da12e8a9..36b9b647af7 100644 --- a/app/assets/javascripts/environments/components/empty_state.vue +++ b/app/assets/javascripts/environments/components/empty_state.vue @@ -12,10 +12,10 @@ export default { <template> <div class="empty-state"> <div class="text-content"> - <h4 class="blank-state-title js-blank-state-title"> + <h4 class="js-blank-state-title"> {{ s__("Environments|You don't have any environments right now") }} </h4> - <p class="blank-state-text"> + <p> {{ s__(`Environments|Environments are places where code gets deployed, such as staging or production.`) diff --git a/app/assets/javascripts/environments/components/environment_pin.vue b/app/assets/javascripts/environments/components/environment_pin.vue index 0b753d53ee3..f5a83b97552 100644 --- a/app/assets/javascripts/environments/components/environment_pin.vue +++ b/app/assets/javascripts/environments/components/environment_pin.vue @@ -6,6 +6,7 @@ import { GlDropdownItem } from '@gitlab/ui'; import { __ } from '~/locale'; import eventHub from '../event_hub'; +import cancelAutoStopMutation from '../graphql/mutations/cancel_auto_stop.mutation.graphql'; export default { components: { @@ -16,10 +17,22 @@ export default { type: String, required: true, }, + graphql: { + type: Boolean, + required: false, + default: false, + }, }, methods: { onPinClick() { - eventHub.$emit('cancelAutoStop', this.autoStopUrl); + if (this.graphql) { + this.$apollo.mutate({ + mutation: cancelAutoStopMutation, + variables: { autoStopUrl: this.autoStopUrl }, + }); + } else { + eventHub.$emit('cancelAutoStop', this.autoStopUrl); + } }, }, title: __('Prevent auto-stopping'), diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index d3624103c13..27a763fb9c4 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -4,10 +4,12 @@ import { GlDropdown, GlButton, GlLink, + GlSprintf, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import { truncate } from '~/lib/utils/text_utility'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import isLastDeployment from '../graphql/queries/is_last_deployment.query.graphql'; import ExternalUrl from './environment_external_url.vue'; import Actions from './environment_actions.vue'; @@ -18,6 +20,7 @@ import Monitoring from './environment_monitoring.vue'; import Terminal from './environment_terminal_button.vue'; import Delete from './environment_delete.vue'; import Deployment from './deployment.vue'; +import DeployBoardWrapper from './deploy_board_wrapper.vue'; export default { components: { @@ -25,19 +28,23 @@ export default { GlDropdown, GlButton, GlLink, + GlSprintf, Actions, Deployment, + DeployBoardWrapper, ExternalUrl, StopComponent, Rollback, Monitoring, Pin, Terminal, + TimeAgoTooltip, Delete, }, directives: { GlTooltip, }, + inject: ['helpPagePath'], props: { environment: { required: true, @@ -60,6 +67,10 @@ export default { i18n: { collapse: __('Collapse'), expand: __('Expand'), + emptyState: s__( + 'Environments|There are no deployments for this environment yet. %{linkStart}Learn more about setting up deployments.%{linkEnd}', + ), + autoStopIn: s__('Environment|Auto stop %{time}'), }, data() { return { visible: false }; @@ -83,12 +94,15 @@ export default { upcomingDeployment() { return this.environment?.upcomingDeployment; }, + hasDeployment() { + return Boolean(this.environment?.upcomingDeployment || this.environment?.lastDeployment); + }, actions() { if (!this.lastDeployment) { return []; } - const { manualActions = [], scheduledActions = [] } = this.lastDeployment; - const combinedActions = [...manualActions, ...scheduledActions]; + const { manualActions, scheduledActions } = this.lastDeployment; + const combinedActions = [...(manualActions ?? []), ...(scheduledActions ?? [])]; return combinedActions.map((action) => ({ ...action, })); @@ -133,6 +147,9 @@ export default { displayName() { return truncate(this.name, 80); }, + rolloutStatus() { + return this.environment?.rolloutStatus; + }, }, methods: { toggleCollapse() { @@ -144,7 +161,15 @@ export default { 'gl-border-t-solid', 'gl-border-1', 'gl-py-5', - 'gl-pl-7', + 'gl-md-pl-7', + 'gl-bg-gray-10', + ], + deployBoardClasses: [ + 'gl-border-gray-100', + 'gl-border-t-solid', + 'gl-border-1', + 'gl-py-4', + 'gl-md-pl-7', 'gl-bg-gray-10', ], }; @@ -176,7 +201,14 @@ export default { {{ displayName }} </gl-link> </div> - <div> + <div class="gl-display-flex gl-align-items-center"> + <p v-if="canShowAutoStopDate" class="gl-font-sm gl-text-gray-700 gl-mr-5 gl-mb-0"> + <gl-sprintf :message="$options.i18n.autoStopIn"> + <template #time> + <time-ago-tooltip :time="environment.autoStopAt" css-class="gl-font-weight-bold" /> + </template> + </gl-sprintf> + </p> <div class="btn-group table-action-buttons" role="group"> <external-url v-if="externalUrl" @@ -224,6 +256,7 @@ export default { <pin v-if="canShowAutoStopDate" :auto-stop-url="autoStopPath" + graphql data-track-action="click_button" data-track-label="environment_pin" /> @@ -254,11 +287,37 @@ export default { </div> </div> <gl-collapse :visible="visible"> - <div v-if="lastDeployment" :class="$options.deploymentClasses"> - <deployment :deployment="lastDeployment" :class="{ 'gl-ml-7': inFolder }" /> + <template v-if="hasDeployment"> + <div v-if="lastDeployment" :class="$options.deploymentClasses"> + <deployment + :deployment="lastDeployment" + :class="{ 'gl-ml-7': inFolder }" + latest + class="gl-pl-4" + /> + </div> + <div v-if="upcomingDeployment" :class="$options.deploymentClasses"> + <deployment + :deployment="upcomingDeployment" + :class="{ 'gl-ml-7': inFolder }" + class="gl-pl-4" + /> + </div> + </template> + <div v-else :class="$options.deploymentClasses"> + <gl-sprintf :message="$options.i18n.emptyState"> + <template #link="{ content }"> + <gl-link :href="helpPagePath">{{ content }}</gl-link> + </template> + </gl-sprintf> </div> - <div v-if="upcomingDeployment" :class="$options.deploymentClasses"> - <deployment :deployment="upcomingDeployment" :class="{ 'gl-ml-7': inFolder }" /> + <div v-if="rolloutStatus" :class="$options.deployBoardClasses"> + <deploy-board-wrapper + :rollout-status="rolloutStatus" + :environment="environment" + :class="{ 'gl-ml-7': inFolder }" + class="gl-pl-4" + /> </div> </gl-collapse> </div> diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue index cb36e226d0e..3699f39b611 100644 --- a/app/assets/javascripts/environments/components/new_environments_app.vue +++ b/app/assets/javascripts/environments/components/new_environments_app.vue @@ -8,16 +8,19 @@ import pageInfoQuery from '../graphql/queries/page_info.query.graphql'; import environmentToDeleteQuery from '../graphql/queries/environment_to_delete.query.graphql'; import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql'; import environmentToStopQuery from '../graphql/queries/environment_to_stop.query.graphql'; +import environmentToChangeCanaryQuery from '../graphql/queries/environment_to_change_canary.query.graphql'; import EnvironmentFolder from './new_environment_folder.vue'; import EnableReviewAppModal from './enable_review_app_modal.vue'; import StopEnvironmentModal from './stop_environment_modal.vue'; import EnvironmentItem from './new_environment_item.vue'; import ConfirmRollbackModal from './confirm_rollback_modal.vue'; import DeleteEnvironmentModal from './delete_environment_modal.vue'; +import CanaryUpdateModal from './canary_update_modal.vue'; export default { components: { DeleteEnvironmentModal, + CanaryUpdateModal, ConfirmRollbackModal, EnvironmentFolder, EnableReviewAppModal, @@ -56,6 +59,12 @@ export default { environmentToStop: { query: environmentToStopQuery, }, + environmentToChangeCanary: { + query: environmentToChangeCanaryQuery, + }, + weight: { + query: environmentToChangeCanaryQuery, + }, }, inject: ['newEnvironmentPath', 'canCreateEnvironment'], i18n: { @@ -80,6 +89,8 @@ export default { environmentToDelete: {}, environmentToRollback: {}, environmentToStop: {}, + environmentToChangeCanary: {}, + weight: 0, }; }, computed: { @@ -186,6 +197,7 @@ export default { <delete-environment-modal :environment="environmentToDelete" graphql /> <stop-environment-modal :environment="environmentToStop" graphql /> <confirm-rollback-modal :environment="environmentToRollback" graphql /> + <canary-update-modal :environment="environmentToChangeCanary" :weight="weight" /> <gl-tabs :action-secondary="addEnvironment" :action-primary="openReviewAppModal" diff --git a/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql index 22dfb8a7a89..0b473495710 100644 --- a/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql +++ b/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql @@ -1,5 +1,5 @@ -mutation cancelAutoStop($environment: LocalEnvironment) { - cancelAutoStop(environment: $environment) @client { +mutation cancelAutoStop($autoStopUrl: String!) { + cancelAutoStop(autoStopUrl: $autoStopUrl) @client { errors } } diff --git a/app/assets/javascripts/environments/graphql/mutations/set_environment_to_change_canary.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_change_canary.mutation.graphql new file mode 100644 index 00000000000..0f48c1f5c05 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_change_canary.mutation.graphql @@ -0,0 +1,3 @@ +mutation SetEnvironmentToChangeCanary($environment: LocalEnvironmentInput, $weight: Int!) { + setEnvironmentToChangeCanary(environment: $environment, weight: $weight) @client +} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_to_change_canary.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_to_change_canary.query.graphql new file mode 100644 index 00000000000..b582ae55ba1 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_to_change_canary.query.graphql @@ -0,0 +1,4 @@ +query environmentToChangeCanary { + environmentToChangeCanary @client + weight @client +} diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index 812fa0c81f0..dc763b77157 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -10,6 +10,7 @@ import pollIntervalQuery from './queries/poll_interval.query.graphql'; import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; import environmentToStopQuery from './queries/environment_to_stop.query.graphql'; import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; +import environmentToChangeCanaryQuery from './queries/environment_to_change_canary.query.graphql'; import pageInfoQuery from './queries/page_info.query.graphql'; const buildErrors = (errors = []) => ({ @@ -134,9 +135,15 @@ export const resolvers = (endpoint) => ({ data: { environmentToRollback: environment }, }); }, - cancelAutoStop(_, { environment: { autoStopPath } }) { + setEnvironmentToChangeCanary(_, { environment, weight }, { client }) { + client.writeQuery({ + query: environmentToChangeCanaryQuery, + data: { environmentToChangeCanary: environment, weight }, + }); + }, + cancelAutoStop(_, { autoStopUrl }) { return axios - .post(autoStopPath) + .post(autoStopUrl) .then(() => buildErrors()) .catch((err) => buildErrors([ diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql index c02f6b2838a..b4d1f7326f6 100644 --- a/app/assets/javascripts/environments/graphql/typedefs.graphql +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -77,9 +77,10 @@ extend type Mutation { stopEnvironment(environment: LocalEnvironmentInput): LocalErrors deleteEnvironment(environment: LocalEnvironmentInput): LocalErrors rollbackEnvironment(environment: LocalEnvironmentInput): LocalErrors - cancelAutoStop(environment: LocalEnvironmentInput): LocalErrors + cancelAutoStop(autoStopUrl: String!): LocalErrors setEnvironmentToDelete(environment: LocalEnvironmentInput): LocalErrors setEnvironmentToRollback(environment: LocalEnvironmentInput): LocalErrors setEnvironmentToStop(environment: LocalEnvironmentInput): LocalErrors + setEnvironmentToChangeCanary(environment: LocalEnvironmentInput, weight: Int): LocalErrors action(environment: LocalEnvironmentInput): LocalErrors } diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js index 0d7a475eb8e..071c95b8f0a 100644 --- a/app/assets/javascripts/files_comment_button.js +++ b/app/assets/javascripts/files_comment_button.js @@ -4,7 +4,7 @@ * causes reflows, visit https://gist.github.com/paulirish/5d52fb081b3570c81e3a */ -import Cookies from 'js-cookie'; +import { getCookie } from '~/lib/utils/common_utils'; const LINE_NUMBER_CLASS = 'diff-line-num'; const UNFOLDABLE_LINE_CLASS = 'js-unfold'; @@ -29,7 +29,7 @@ export default { $diffFile.closest(DIFF_CONTAINER_SELECTOR).data('canCreateNote') === ''; } - this.isParallelView = Cookies.get('diff_view') === 'parallel'; + this.isParallelView = getCookie('diff_view') === 'parallel'; if (this.userCanCreateNote) { $diffFile 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 d00e6e59cf5..28a3c54cc8f 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 @@ -13,6 +13,21 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { IssuableTokenKeys.tokenKeys.splice(2, 0, reviewerToken); IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, reviewerToken); + if (window.gon?.features?.mrAttentionRequests) { + const attentionRequestedToken = { + formattedKey: __('Attention'), + key: 'attention', + type: 'string', + param: '', + symbol: '@', + icon: 'user', + tag: '@attention', + hideNotEqual: true, + }; + IssuableTokenKeys.tokenKeys.splice(2, 0, attentionRequestedToken); + IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, attentionRequestedToken); + } + const draftToken = { token: { formattedKey: __('Draft'), diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index 3cd4d48a4a3..09cef74477c 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -77,6 +77,11 @@ export default class AvailableDropdownMappings { gl: DropdownUser, element: this.container.querySelector('#js-dropdown-reviewer'), }, + attention: { + reference: null, + gl: DropdownUser, + element: this.container.getElementById('js-dropdown-attention-requested'), + }, 'approved-by': { reference: null, gl: DropdownUser, diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index e2d6936acbd..f8b5910de9e 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', 'approved-by', 'reviewer']; +export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by', 'reviewer', 'attention']; export const DROPDOWN_TYPE = { hint: 'hint', diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index d9c2e55cffe..fa605f8c056 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -18,6 +18,13 @@ const VARIANT_DANGER = 'danger'; const VARIANT_INFO = 'info'; const VARIANT_TIP = 'tip'; +const TYPE_TO_VARIANT = { + [FLASH_TYPES.ALERT]: VARIANT_DANGER, + [FLASH_TYPES.NOTICE]: VARIANT_INFO, + [FLASH_TYPES.SUCCESS]: VARIANT_SUCCESS, + [FLASH_TYPES.WARNING]: VARIANT_WARNING, +}; + const FLASH_CLOSED_EVENT = 'flashClosed'; const getCloseEl = (flashEl) => { @@ -61,7 +68,7 @@ const createAction = (config) => ` `; const createFlashEl = (message, type) => ` - <div class="flash-${type}"> + <div class="flash-${type}" data-testid="alert-${TYPE_TO_VARIANT[type]}"> <div class="flash-text"> ${escape(message)} <div class="close-icon-wrapper js-close-icon"> @@ -189,6 +196,9 @@ const createAlert = function createAlert({ secondaryButtonLink: secondaryButton?.link, secondaryButtonText: secondaryButton?.text, }, + attrs: { + 'data-testid': `alert-${variant}`, + }, on, }, message, diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 69331ff1a06..bf29a356abd 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -86,6 +86,7 @@ export const defaultAutocompleteConfig = { labels: true, snippets: true, vulnerabilities: true, + contacts: true, }; class GfmAutoComplete { @@ -127,6 +128,7 @@ class GfmAutoComplete { if (this.enableMap.mergeRequests) this.setupMergeRequests($input); if (this.enableMap.labels) this.setupLabels($input); if (this.enableMap.snippets) this.setupSnippets($input); + if (this.enableMap.contacts) this.setupContacts($input); $input.filter('[data-supports-quick-actions="true"]').atwho({ at: '/', @@ -174,9 +176,16 @@ class GfmAutoComplete { let tpl = '/${name} '; let referencePrefix = null; if (value.params.length > 0) { - [[referencePrefix]] = value.params; - if (/^[@%~]/.test(referencePrefix)) { + const regexp = /\[[a-z]+:/; + const match = regexp.exec(value.params); + if (match) { + [referencePrefix] = match; tpl += '<%- referencePrefix %>'; + } else { + [[referencePrefix]] = value.params; + if (/^[@%~]/.test(referencePrefix)) { + tpl += '<%- referencePrefix %>'; + } } } return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix }); @@ -266,6 +275,8 @@ class GfmAutoComplete { UNASSIGN_REVIEWER: '/unassign_reviewer', REASSIGN: '/reassign', CC: '/cc', + ATTENTION: '/attention', + REMOVE_ATTENTION: '/remove_attention', }; let assignees = []; let reviewers = []; @@ -344,6 +355,23 @@ class GfmAutoComplete { } else if (command === MEMBER_COMMAND.UNASSIGN_REVIEWER) { // Only include members which are not assigned as a reviewer to Issuable currently return data.filter((member) => reviewers.includes(member.search)); + } else if ( + command === MEMBER_COMMAND.ATTENTION || + command === MEMBER_COMMAND.REMOVE_ATTENTION + ) { + const attentionUsers = [ + ...(SidebarMediator.singleton?.store?.assignees || []), + ...(SidebarMediator.singleton?.store?.reviewers || []), + ]; + const attentionRequested = command === MEMBER_COMMAND.REMOVE_ATTENTION; + + return data.filter((member) => + attentionUsers.find( + (u) => + createMemberSearchString(u).includes(member.search) && + u.attention_requested === attentionRequested, + ), + ); } return data; @@ -619,6 +647,42 @@ class GfmAutoComplete { }); } + setupContacts($input) { + $input.atwho({ + at: '[contact:', + suffix: ']', + alias: 'contacts', + searchKey: 'search', + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.email != null) { + tmpl = GfmAutoComplete.Contacts.templateFunction(value); + } + return tmpl; + }, + data: GfmAutoComplete.defaultLoadingData, + // eslint-disable-next-line no-template-curly-in-string + insertTpl: '${atwho-at}${email}', + callbacks: { + ...this.getDefaultCallbacks(), + beforeSave(contacts) { + return $.map(contacts, (m) => { + if (m.email == null) { + return m; + } + return { + id: m.id, + email: m.email, + firstName: m.first_name, + lastName: m.last_name, + search: `${m.email}`, + }; + }); + }, + }, + }); + } + getDefaultCallbacks() { const self = this; @@ -790,6 +854,7 @@ GfmAutoComplete.atTypeMap = { '/': 'commands', '[vulnerability:': 'vulnerabilities', $: 'snippets', + '[contact:': 'contacts', }; GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities']; @@ -883,6 +948,11 @@ GfmAutoComplete.Milestones = { return `<li>${escape(title)}</li>`; }, }; +GfmAutoComplete.Contacts = { + templateFunction({ email, firstName, lastName }) { + return `<li><small>${firstName} ${lastName}</small> ${escape(email)}</li>`; + }, +}; GfmAutoComplete.Loading = { template: '<li style="pointer-events: none;"><span class="spinner align-text-bottom mr-1"></span>Loading...</li>', diff --git a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue b/app/assets/javascripts/google_cloud/components/deployments_service_table.vue index 7d27d7cf6b2..26c9fd14dc6 100644 --- a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue +++ b/app/assets/javascripts/google_cloud/components/deployments_service_table.vue @@ -2,6 +2,9 @@ import { GlButton, GlTable } from '@gitlab/ui'; import { __ } from '~/locale'; +const cloudRun = 'cloudRun'; +const cloudStorage = 'cloudStorage'; + const i18n = { cloudRun: __('Cloud Run'), cloudRunDescription: __('Deploy container based web apps on Google managed clusters'), @@ -28,6 +31,13 @@ export default { required: true, }, }, + methods: { + actionUrl(key) { + if (key === cloudRun) return this.cloudRunUrl; + else if (key === cloudStorage) return this.cloudStorageUrl; + return '#'; + }, + }, fields: [ { key: 'title', label: i18n.service }, { key: 'description', label: i18n.description }, @@ -37,12 +47,19 @@ export default { { title: i18n.cloudRun, description: i18n.cloudRunDescription, - action: { title: i18n.configureViaMergeRequest, disabled: true }, + action: { + key: cloudRun, + title: i18n.configureViaMergeRequest, + }, }, { title: i18n.cloudStorage, description: i18n.cloudStorageDescription, - action: { title: i18n.configureViaMergeRequest, disabled: true }, + action: { + key: cloudStorage, + title: i18n.configureViaMergeRequest, + disabled: true, + }, }, ], i18n, @@ -54,7 +71,9 @@ export default { <p>{{ $options.i18n.deploymentsDescription }}</p> <gl-table :fields="$options.fields" :items="$options.items"> <template #cell(action)="{ value }"> - <gl-button :disabled="value.disabled">{{ value.title }}</gl-button> + <gl-button :disabled="value.disabled" :href="actionUrl(value.key)"> + {{ value.title }} + </gl-button> </template> </gl-table> </div> diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue index 8ef110dcf22..c08d8bb7c51 100644 --- a/app/assets/javascripts/google_cloud/components/home.vue +++ b/app/assets/javascripts/google_cloud/components/home.vue @@ -23,11 +23,11 @@ export default { type: String, required: true, }, - deploymentsCloudRunUrl: { + enableCloudRunUrl: { type: String, required: true, }, - deploymentsCloudStorageUrl: { + enableCloudStorageUrl: { type: String, required: true, }, @@ -47,8 +47,8 @@ export default { </gl-tab> <gl-tab :title="__('Deployments')"> <deployments-service-table - :cloud-run-url="deploymentsCloudRunUrl" - :cloud-storage-url="deploymentsCloudStorageUrl" + :cloud-run-url="enableCloudRunUrl" + :cloud-storage-url="enableCloudStorageUrl" /> </gl-tab> <gl-tab :title="__('Services')" disabled /> diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue index e7a09668473..551783e6c50 100644 --- a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue +++ b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue @@ -1,9 +1,9 @@ <script> -import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox } from '@gitlab/ui'; import { __ } from '~/locale'; export default { - components: { GlButton, GlFormGroup, GlFormSelect }, + components: { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox }, props: { gcpProjects: { required: true, type: Array }, environments: { required: true, type: Array }, @@ -19,6 +19,9 @@ export default { environmentDescription: __('Generated service account is linked to the selected environment'), submitLabel: __('Create service account'), cancelLabel: __('Cancel'), + checkboxLabel: __( + 'I understand the responsibilities involved with managing service account keys', + ), }, }; </script> @@ -59,6 +62,11 @@ export default { </option> </gl-form-select> </gl-form-group> + <gl-form-group> + <gl-form-checkbox name="confirmation" required> + {{ $options.i18n.checkboxLabel }} + </gl-form-checkbox> + </gl-form-group> <div class="form-actions row"> <gl-button type="submit" category="primary" variant="confirm"> diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue index b70b25a5dc3..4db84746482 100644 --- a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue +++ b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue @@ -1,9 +1,9 @@ <script> -import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; +import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf, GlTable } from '@gitlab/ui'; import { __ } from '~/locale'; export default { - components: { GlButton, GlEmptyState, GlTable }, + components: { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf, GlTable }, props: { list: { type: Array, @@ -28,6 +28,22 @@ export default { ], }; }, + i18n: { + createServiceAccount: __('Create service account'), + found: __('✔'), + notFound: __('Not found'), + noServiceAccountsTitle: __('No service accounts'), + noServiceAccountsDescription: __( + 'Service Accounts keys authorize GitLab to deploy your Google Cloud project', + ), + serviceAccountsTitle: __('Service accounts'), + serviceAccountsDescription: __( + 'Service Accounts keys authorize GitLab to deploy your Google Cloud project', + ), + secretManagersDescription: __( + 'Enhance security by storing service account keys in secret managers - learn more about %{docLinkStart}secret management with GitLab%{docLinkEnd}', + ), + }, }; </script> @@ -35,31 +51,39 @@ export default { <div> <gl-empty-state v-if="list.length === 0" - :title="__('No service accounts')" - :description=" - __('Service Accounts keys authorize GitLab to deploy your Google Cloud project') - " + :title="$options.i18n.noServiceAccountsTitle" + :description="$options.i18n.noServiceAccountsDescription" :primary-button-link="createUrl" - :primary-button-text="__('Create service account')" + :primary-button-text="$options.i18n.createServiceAccount" :svg-path="emptyIllustrationUrl" /> <div v-else> - <h2 class="gl-font-size-h2">{{ __('Service Accounts') }}</h2> - <p>{{ __('Service Accounts keys authorize GitLab to deploy your Google Cloud project') }}</p> + <h2 class="gl-font-size-h2">{{ $options.i18n.serviceAccountsTitle }}</h2> + <p>{{ $options.i18n.serviceAccountsDescription }}</p> <gl-table :items="list" :fields="tableFields"> <template #cell(service_account_exists)="{ value }"> - {{ value ? '✔' : __('Not found') }} + {{ value ? $options.i18n.found : $options.i18n.notFound }} </template> <template #cell(service_account_key_exists)="{ value }"> - {{ value ? '✔' : __('Not found') }} + {{ value ? $options.i18n.found : $options.i18n.notFound }} </template> </gl-table> <gl-button :href="createUrl" category="primary" variant="info"> - {{ __('Create service account') }} + {{ $options.i18n.createServiceAccount }} </gl-button> + + <gl-alert class="gl-mt-5" :dismissible="false" variant="tip"> + <gl-sprintf :message="$options.i18n.secretManagersDescription"> + <template #docLink="{ content }"> + <gl-link href="https://docs.gitlab.com/ee/ci/secrets/"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> </div> </div> </template> diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js index ab80e15c2ec..55987ce64e6 100644 --- a/app/assets/javascripts/google_tag_manager/index.js +++ b/app/assets/javascripts/google_tag_manager/index.js @@ -1,5 +1,43 @@ +import { v4 as uuidv4 } from 'uuid'; import { logError } from '~/lib/logger'; +const SKU_PREMIUM = '2c92a00d76f0d5060176f2fb0a5029ff'; +const SKU_ULTIMATE = '2c92a0ff76f0d5250176f2f8c86f305a'; +const PRODUCT_INFO = { + [SKU_PREMIUM]: { + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Premium', + id: '0002', + price: '228', + variant: 'SaaS', + }, + [SKU_ULTIMATE]: { + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Ultimate', + id: '0001', + price: '1188', + variant: 'SaaS', + }, +}; + +const generateProductInfo = (sku, quantity) => { + const product = PRODUCT_INFO[sku]; + + if (!product) { + logError('Unexpected product sku provided to generateProductInfo'); + return {}; + } + + const productInfo = { + ...product, + brand: 'GitLab', + category: 'DevOps', + quantity, + }; + + return productInfo; +}; + const isSupported = () => Boolean(window.dataLayer) && gon.features?.gitlabGtmDatalayer; const pushEvent = (event, args = {}) => { @@ -17,6 +55,22 @@ const pushEvent = (event, args = {}) => { } }; +const pushEnhancedEcommerceEvent = (event, args = {}) => { + if (!window.dataLayer) { + return; + } + + try { + window.dataLayer.push({ ecommerce: null }); // Clear the previous ecommerce object + window.dataLayer.push({ + event, + ...args, + }); + } catch (e) { + logError('Unexpected error while pushing to dataLayer', e); + } +}; + const pushAccountSubmit = (accountType, accountMethod) => pushEvent('accountSubmit', { accountType, accountMethod }); @@ -120,3 +174,60 @@ export const trackSaasTrialGetStarted = () => { pushEvent('saasTrialGetStarted'); }); }; + +export const trackCheckout = (selectedPlan, quantity) => { + if (!isSupported()) { + return; + } + + const product = generateProductInfo(selectedPlan, quantity); + + if (Object.keys(product).length === 0) { + return; + } + + const eventData = { + ecommerce: { + currencyCode: 'USD', + checkout: { + actionField: { step: 1 }, + products: [product], + }, + }, + }; + + // eslint-disable-next-line @gitlab/require-i18n-strings + pushEnhancedEcommerceEvent('EECCheckout', eventData); +}; + +export const trackTransaction = (transactionDetails) => { + if (!isSupported()) { + return; + } + + const transactionId = uuidv4(); + const { paymentOption, revenue, tax, selectedPlan, quantity } = transactionDetails; + const product = generateProductInfo(selectedPlan, quantity); + + if (Object.keys(product).length === 0) { + return; + } + + const eventData = { + ecommerce: { + currencyCode: 'USD', + purchase: { + actionField: { + id: transactionId, + affiliation: 'GitLab', + option: paymentOption, + revenue: revenue.toString(), + tax: tax.toString(), + }, + products: [product], + }, + }, + }; + + pushEnhancedEcommerceEvent('EECtransactionSuccess', eventData); +}; diff --git a/app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js b/app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js deleted file mode 100644 index 30888e20a46..00000000000 --- a/app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js +++ /dev/null @@ -1,17 +0,0 @@ -export const vulnerabilityLocationTypes = { - __schema: { - types: [ - { - kind: 'UNION', - name: 'VulnerabilityLocation', - possibleTypes: [ - { name: 'VulnerabilityLocationContainerScanning' }, - { name: 'VulnerabilityLocationDast' }, - { name: 'VulnerabilityLocationDependencyScanning' }, - { name: 'VulnerabilityLocationSast' }, - { name: 'VulnerabilityLocationSecretDetection' }, - ], - }, - ], - }, -}; diff --git a/app/assets/javascripts/graphql_shared/possibleTypes.json b/app/assets/javascripts/graphql_shared/possibleTypes.json new file mode 100644 index 00000000000..9a24d2a3afc --- /dev/null +++ b/app/assets/javascripts/graphql_shared/possibleTypes.json @@ -0,0 +1 @@ +{"AlertManagementIntegration":["AlertManagementHttpIntegration","AlertManagementPrometheusIntegration"],"CurrentUserTodos":["BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest"],"DependencyLinkMetadata":["NugetDependencyLinkMetadata"],"DesignFields":["Design","DesignAtVersion"],"Entry":["Blob","Submodule","TreeEntry"],"Eventable":["BoardEpic","Epic"],"Issuable":["Epic","Issue","MergeRequest"],"JobNeedUnion":["CiBuildNeed","CiJob"],"MemberInterface":["GroupMember","ProjectMember"],"NoteableInterface":["AlertManagementAlert","BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest","Snippet","Vulnerability"],"NoteableType":["Design","Issue","MergeRequest"],"OrchestrationPolicy":["ScanExecutionPolicy","ScanResultPolicy"],"PackageFileMetadata":["ConanFileMetadata","HelmFileMetadata"],"PackageMetadata":["ComposerMetadata","ConanMetadata","MavenMetadata","NugetMetadata","PypiMetadata"],"ResolvableInterface":["Discussion","Note"],"Service":["BaseService","JiraService"],"TimeboxReportInterface":["Iteration","Milestone"],"User":["MergeRequestAssignee","MergeRequestReviewer","UserCore"],"VulnerabilityDetail":["VulnerabilityDetailBase","VulnerabilityDetailBoolean","VulnerabilityDetailCode","VulnerabilityDetailCommit","VulnerabilityDetailDiff","VulnerabilityDetailFileLocation","VulnerabilityDetailInt","VulnerabilityDetailList","VulnerabilityDetailMarkdown","VulnerabilityDetailModuleLocation","VulnerabilityDetailTable","VulnerabilityDetailText","VulnerabilityDetailUrl"],"VulnerabilityLocation":["VulnerabilityLocationClusterImageScanning","VulnerabilityLocationContainerScanning","VulnerabilityLocationCoverageFuzzing","VulnerabilityLocationDast","VulnerabilityLocationDependencyScanning","VulnerabilityLocationGeneric","VulnerabilityLocationSast","VulnerabilityLocationSecretDetection"]} diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index a1ec5942d64..e3147065d5c 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -41,6 +41,7 @@ export default { }, data() { return { + isModalVisible: false, isLoading: true, isSearchEmpty: false, searchEmptyMessage: '', @@ -101,6 +102,12 @@ export default { eventHub.$off(`${this.action}updateGroups`, this.updateGroups); }, methods: { + hideModal() { + this.isModalVisible = false; + }, + showModal() { + this.isModalVisible = true; + }, fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) { return this.service .getGroups(parentId, page, filterGroupsBy, sortBy, archived) @@ -185,6 +192,7 @@ export default { showLeaveGroupModal(group, parentGroup) { this.targetGroup = group; this.targetParentGroup = parentGroup; + this.showModal(); }, leaveGroup() { this.targetGroup.isBeingRemoved = true; @@ -256,10 +264,12 @@ export default { /> <gl-modal modal-id="leave-group-modal" + :visible="isModalVisible" :title="__('Are you sure?')" :action-primary="primaryProps" :action-cancel="cancelProps" @primary="leaveGroup" + @hide="hideModal" > {{ groupLeaveConfirmationMessage }} </gl-modal> diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 10c45abbfa2..707008ec493 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -34,8 +34,8 @@ export default { ), itemCaret, itemTypeIcon, - itemStats, itemActions, + itemStats, }, props: { parentGroup: { @@ -92,6 +92,9 @@ export default { complianceFramework() { return this.group.complianceFramework; }, + showActionsMenu() { + return this.isGroup && (this.group.canEdit || this.group.canRemove || this.group.canLeave); + }, }, methods: { onClickRowGroup(e) { @@ -197,17 +200,19 @@ export default { <div v-if="isGroupPendingRemoval"> <gl-badge variant="warning">{{ __('pending deletion') }}</gl-badge> </div> - <div class="metadata d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between"> + <div + class="metadata gl-display-flex gl-flex-grow-1 gl-flex-shrink-0 gl-flex-wrap justify-content-md-between" + > + <item-stats + :item="group" + class="group-stats gl-mt-2 gl-display-none gl-md-display-flex gl-align-items-center" + /> <item-actions - v-if="isGroup" + v-if="showActionsMenu" :group="group" :parent-group="parentGroup" :action="action" /> - <item-stats - :item="group" - class="group-stats gl-mt-2 d-none d-md-flex gl-align-items-center" - /> </div> </div> </div> diff --git a/app/assets/javascripts/groups/components/invite_members_banner.vue b/app/assets/javascripts/groups/components/invite_members_banner.vue index dfc1549fb4a..7afea815197 100644 --- a/app/assets/javascripts/groups/components/invite_members_banner.vue +++ b/app/assets/javascripts/groups/components/invite_members_banner.vue @@ -46,7 +46,6 @@ export default { }, openModal() { eventHub.$emit('openModal', { - inviteeType: 'members', source: this.$options.openModalSource, }); this.track(this.$options.buttonClickEvent); diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue index df751a3f37e..fc7cfffc22c 100644 --- a/app/assets/javascripts/groups/components/item_actions.vue +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -1,15 +1,17 @@ <script> -import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui'; +import { GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { COMMON_STR } from '../constants'; import eventHub from '../event_hub'; +const { LEAVE_BTN_TITLE, EDIT_BTN_TITLE, REMOVE_BTN_TITLE, OPTIONS_DROPDOWN_TITLE } = COMMON_STR; + export default { components: { - GlButton, + GlDropdown, + GlDropdownItem, }, directives: { GlTooltip: GlTooltipDirective, - GlModal: GlModalDirective, }, props: { parentGroup: { @@ -28,11 +30,8 @@ export default { }, }, computed: { - leaveBtnTitle() { - return COMMON_STR.LEAVE_BTN_TITLE; - }, - editBtnTitle() { - return COMMON_STR.EDIT_BTN_TITLE; + removeButtonHref() { + return `${this.group.editPath}#js-remove-group-form`; }, }, methods: { @@ -40,33 +39,51 @@ export default { eventHub.$emit(`${this.action}showLeaveGroupModal`, this.group, this.parentGroup); }, }, + i18n: { + leaveBtnTitle: LEAVE_BTN_TITLE, + editBtnTitle: EDIT_BTN_TITLE, + removeBtnTitle: REMOVE_BTN_TITLE, + optionsDropdownTitle: OPTIONS_DROPDOWN_TITLE, + }, }; </script> <template> - <div class="controls d-flex justify-content-end"> - <gl-button - v-if="group.canLeave" - v-gl-tooltip.top - v-gl-modal.leave-group-modal - :title="leaveBtnTitle" - :aria-label="leaveBtnTitle" - data-testid="leave-group-btn" - size="small" - icon="leave" - class="leave-group gl-ml-3" - @click.stop="onLeaveGroup" - /> - <gl-button - v-if="group.canEdit" - v-gl-tooltip.top - :href="group.editPath" - :title="editBtnTitle" - :aria-label="editBtnTitle" - data-testid="edit-group-btn" - size="small" - icon="pencil" - class="edit-group gl-ml-3" - /> + <div class="gl-display-flex gl-justify-content-end gl-ml-5"> + <gl-dropdown + v-gl-tooltip.hover.focus="$options.i18n.optionsDropdownTitle" + right + category="tertiary" + icon="ellipsis_v" + no-caret + :data-testid="`group-${group.id}-dropdown-button`" + data-qa-selector="group_dropdown_button" + :data-qa-group-id="group.id" + > + <gl-dropdown-item + v-if="group.canEdit" + :data-testid="`edit-group-${group.id}-btn`" + :href="group.editPath" + @click.stop + > + {{ $options.i18n.editBtnTitle }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="group.canLeave" + :data-testid="`leave-group-${group.id}-btn`" + @click.stop="onLeaveGroup" + > + {{ $options.i18n.leaveBtnTitle }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="group.canRemove" + :href="removeButtonHref" + :data-testid="`remove-group-${group.id}-btn`" + variant="danger" + @click.stop + > + {{ $options.i18n.removeBtnTitle }} + </gl-dropdown-item> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/groups/components/transfer_group_form.vue b/app/assets/javascripts/groups/components/transfer_group_form.vue new file mode 100644 index 00000000000..e848f10352d --- /dev/null +++ b/app/assets/javascripts/groups/components/transfer_group_form.vue @@ -0,0 +1,80 @@ +<script> +import { GlFormGroup } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; +import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; + +export const i18n = { + confirmationMessage: __( + 'You are going to transfer %{group_name} to another namespace. Are you ABSOLUTELY sure?', + ), + emptyNamespaceTitle: __('No parent group'), + dropdownTitle: s__('GroupSettings|Select parent group'), +}; + +export default { + name: 'TransferGroupForm', + components: { + ConfirmDanger, + GlFormGroup, + NamespaceSelect, + }, + props: { + groupNamespaces: { + type: Array, + required: true, + }, + isPaidGroup: { + type: Boolean, + required: true, + }, + confirmationPhrase: { + type: String, + required: true, + }, + confirmButtonText: { + type: String, + required: true, + }, + }, + data() { + return { + selectedId: null, + }; + }, + computed: { + disableSubmitButton() { + return this.isPaidGroup || !this.selectedId; + }, + }, + methods: { + handleSelected({ id }) { + this.selectedId = id; + }, + }, + i18n, +}; +</script> +<template> + <div> + <gl-form-group v-if="!isPaidGroup"> + <namespace-select + :default-text="$options.i18n.dropdownTitle" + :group-namespaces="groupNamespaces" + :empty-namespace-title="$options.i18n.emptyNamespaceTitle" + :include-headers="false" + include-empty-namespace + data-testid="transfer-group-namespace-select" + @select="handleSelected" + /> + <input type="hidden" name="new_parent_group_id" :value="selectedId" /> + </gl-form-group> + <confirm-danger + button-class="qa-transfer-button" + :disabled="disableSubmitButton" + :phrase="confirmationPhrase" + :button-text="confirmButtonText" + @confirm="$emit('confirm')" + /> + </div> +</template> diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index e2722d780dc..005bac1e7b5 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -15,8 +15,10 @@ export const COMMON_STR = { LEAVE_FORBIDDEN: s__( 'GroupsTree|Failed to leave the group. Please make sure you are not the only owner.', ), - LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'), - EDIT_BTN_TITLE: s__('GroupsTree|Edit group'), + LEAVE_BTN_TITLE: s__('GroupsTree|Leave group'), + EDIT_BTN_TITLE: s__('GroupsTree|Edit'), + REMOVE_BTN_TITLE: s__('GroupsTree|Delete'), + OPTIONS_DROPDOWN_TITLE: s__('GroupsTree|Options'), GROUP_SEARCH_EMPTY: s__('GroupsTree|No groups matched your search'), GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|No groups or projects matched your search'), }; diff --git a/app/assets/javascripts/groups/init_transfer_group_form.js b/app/assets/javascripts/groups/init_transfer_group_form.js new file mode 100644 index 00000000000..f055b926918 --- /dev/null +++ b/app/assets/javascripts/groups/init_transfer_group_form.js @@ -0,0 +1,52 @@ +import Vue from 'vue'; +import { sprintf } from '~/locale'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import TransferGroupForm, { i18n } from './components/transfer_group_form.vue'; + +const prepareGroups = (rawGroups) => { + if (!rawGroups) { + return []; + } + + return JSON.parse(rawGroups).map(({ id, text: humanName }) => ({ + id, + humanName, + })); +}; + +export default () => { + const el = document.querySelector('.js-transfer-group-form'); + if (!el) { + return false; + } + + const { + targetFormId = null, + buttonText: confirmButtonText = '', + groupName = '', + parentGroups, + isPaidGroup, + } = el.dataset; + + return new Vue({ + el, + provide: { + confirmDangerMessage: sprintf(i18n.confirmationMessage, { group_name: groupName }), + }, + render(createElement) { + return createElement(TransferGroupForm, { + props: { + groupNamespaces: prepareGroups(parentGroups), + isPaidGroup: parseBoolean(isPaidGroup), + confirmButtonText, + confirmationPhrase: groupName, + }, + on: { + confirm: () => { + document.getElementById(targetFormId)?.submit(); + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/groups/landing.js b/app/assets/javascripts/groups/landing.js index bfb4d9ce67b..ed76bebf843 100644 --- a/app/assets/javascripts/groups/landing.js +++ b/app/assets/javascripts/groups/landing.js @@ -1,5 +1,4 @@ -import Cookies from 'js-cookie'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils'; class Landing { constructor(landingElement, dismissButton, cookieName) { @@ -27,11 +26,11 @@ class Landing { dismissLanding() { this.landingElement.classList.add('hidden'); - Cookies.set(this.cookieName, 'true', { expires: 365 }); + setCookie(this.cookieName, 'true'); } isDismissed() { - return parseBoolean(Cookies.get(this.cookieName)); + return parseBoolean(getCookie(this.cookieName)); } } diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js index d3600bd223a..0917b9ceccf 100644 --- a/app/assets/javascripts/groups/store/groups_store.js +++ b/app/assets/javascripts/groups/store/groups_store.js @@ -83,6 +83,7 @@ export default class GroupsStore { leavePath: rawGroupItem.leave_path, canEdit: rawGroupItem.can_edit, canLeave: rawGroupItem.can_leave, + canRemove: rawGroupItem.can_remove, type: rawGroupItem.type, permission: rawGroupItem.permission, children: groupChildren, diff --git a/app/assets/javascripts/groups/transfer_dropdown.js b/app/assets/javascripts/groups/transfer_dropdown.js deleted file mode 100644 index d6343f698c0..00000000000 --- a/app/assets/javascripts/groups/transfer_dropdown.js +++ /dev/null @@ -1,39 +0,0 @@ -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { __ } from '~/locale'; - -export default class TransferDropdown { - constructor() { - this.groupDropdown = $('.js-groups-dropdown'); - this.parentInput = $('#new_parent_group_id'); - this.data = this.groupDropdown.data('data'); - this.init(); - } - - init() { - this.buildDropdown(); - } - - buildDropdown() { - const extraOptions = [{ id: '-1', text: __('No parent group') }, { type: 'divider' }]; - - initDeprecatedJQueryDropdown(this.groupDropdown, { - selectable: true, - filterable: true, - toggleLabel: (item) => item.text, - search: { fields: ['text'] }, - data: extraOptions.concat(this.data), - text: (item) => item.text, - clicked: (options) => { - const { e } = options; - e.preventDefault(); - this.assignSelected(options.selectedObj); - }, - }); - } - - assignSelected(selected) { - this.parentInput.val(selected.id); - this.parentInput.change(); - } -} diff --git a/app/assets/javascripts/groups/transfer_edit.js b/app/assets/javascripts/groups/transfer_edit.js deleted file mode 100644 index bb15e11fd4c..00000000000 --- a/app/assets/javascripts/groups/transfer_edit.js +++ /dev/null @@ -1,11 +0,0 @@ -import $ from 'jquery'; - -export default function setupTransferEdit(formSelector, targetSelector) { - const $transferForm = $(formSelector); - const $selectNamespace = $transferForm.find(targetSelector); - - $selectNamespace.on('change', () => { - $transferForm.find(':submit').prop('disabled', !$selectNamespace.val()); - }); - $selectNamespace.trigger('change'); -} diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index bd71c5ebc11..64bba91eb4d 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -28,6 +28,7 @@ const groupsSelect = () => { const skipGroups = $select.data('skipGroups') || []; const parentGroupID = $select.data('parentId'); const groupsFilter = $select.data('groupsFilter'); + const minAccessLevel = $select.data('minAccessLevel'); $select.select2({ placeholder: __('Search for a group'), @@ -45,6 +46,7 @@ const groupsSelect = () => { page, per_page: window.GROUP_SELECT_PER_PAGE, all_available: allAvailable, + min_access_level: minAccessLevel, }; }, results(data, page) { diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index 846b4d92724..92dacf8c94a 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { leftSidebarViews } from '../constants'; @@ -7,6 +7,7 @@ import { leftSidebarViews } from '../constants'; export default { components: { GlIcon, + GlBadge, }, directives: { GlTooltip: GlTooltipDirective, @@ -82,9 +83,13 @@ export default { @click.prevent="changedActivityView($event, $options.leftSidebarViews.commit.name)" > <gl-icon name="commit" /> - <div v-if="stagedFiles.length > 0" class="ide-commit-badge badge badge-pill"> + <gl-badge + v-if="stagedFiles.length" + class="gl-absolute gl-px-2 gl-top-3 gl-right-3 gl-font-weight-bold gl-bg-gray-900! gl-text-white!" + size="sm" + > {{ stagedFiles.length }} - </div> + </gl-badge> </button> </li> </ul> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 9ec4a07a3d0..44f543d9a76 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -187,7 +187,7 @@ export default { class="qa-commit-button" category="primary" variant="confirm" - @click="commit" + type="submit" > {{ __('Commit') }} </gl-button> diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue index 13f2e775fc3..b1f6f2c87b9 100644 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -4,7 +4,12 @@ import { listen } from 'codesandbox-api'; import { isEmpty, debounce } from 'lodash'; import { Manager } from 'smooshpack'; import { mapActions, mapGetters, mapState } from 'vuex'; -import { packageJsonPath, LIVE_PREVIEW_DEBOUNCE } from '../../constants'; +import { + packageJsonPath, + LIVE_PREVIEW_DEBOUNCE, + PING_USAGE_PREVIEW_KEY, + PING_USAGE_PREVIEW_SUCCESS_KEY, +} from '../../constants'; import eventHub from '../../eventhub'; import { createPathWithExt } from '../../utils'; import Navigator from './navigator.vue'; @@ -62,6 +67,15 @@ export default { }; }, }, + watch: { + sandpackReady: { + handler(val) { + if (val) { + this.pingUsage(PING_USAGE_PREVIEW_SUCCESS_KEY); + } + }, + }, + }, mounted() { this.onFilesChangeCallback = debounce(() => this.update(), LIVE_PREVIEW_DEBOUNCE); eventHub.$on('ide.files.change', this.onFilesChangeCallback); @@ -101,7 +115,7 @@ export default { initPreview() { if (!this.mainEntry) return null; - this.pingUsage(); + this.pingUsage(PING_USAGE_PREVIEW_KEY); return this.loadFileContent(this.mainEntry) .then(() => this.$nextTick()) diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 775b6906498..bfe4c3ac271 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -114,3 +114,7 @@ export const LIVE_PREVIEW_DEBOUNCE = 2000; export const MAX_MR_FILES_AUTO_OPEN = 10; export const DEFAULT_BRANCH = 'main'; + +// Ping Usage Metrics Keys +export const PING_USAGE_PREVIEW_KEY = 'web_ide_clientside_preview'; +export const PING_USAGE_PREVIEW_SUCCESS_KEY = 'web_ide_clientside_preview_success'; diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js index e36419cd7eb..1a8e665867f 100644 --- a/app/assets/javascripts/ide/stores/modules/clientside/actions.js +++ b/app/assets/javascripts/ide/stores/modules/clientside/actions.js @@ -1,9 +1,9 @@ import axios from '~/lib/utils/axios_utils'; -export const pingUsage = ({ rootGetters }) => { +export const pingUsage = ({ rootGetters }, metricName) => { const { web_url: projectUrl } = rootGetters.currentProject; - const url = `${projectUrl}/service_ping/web_ide_clientside_preview`; + const url = `${projectUrl}/service_ping/${metricName}`; return axios.post(url); }; diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js index 8ee72235a23..5ff00394e3b 100644 --- a/app/assets/javascripts/image_diff/helpers/badge_helper.js +++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js @@ -14,7 +14,15 @@ export function createImageBadge(noteId, { x, y }, classNames = []) { } export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) { - const buttonEl = createImageBadge(noteId, coordinate, ['badge', 'badge-pill']); + const buttonEl = createImageBadge(noteId, coordinate, [ + 'gl-display-flex', + 'gl-align-items-center', + 'gl-justify-content-center', + 'gl-font-sm', + 'design-note-pin', + 'on-image', + 'gl-absolute', + ]); buttonEl.textContent = badgeText; containerEl.appendChild(buttonEl); @@ -30,8 +38,8 @@ export function addImageCommentBadge(containerEl, { coordinate, noteId }) { export function addAvatarBadge(el, event) { const { noteId, badgeNumber } = event.detail; - // Add badge to new comment - const avatarBadgeEl = el.querySelector(`#${noteId} .badge`); + // Add design pin to new comment + const avatarBadgeEl = el.querySelector(`#${noteId} .design-note-pin`); avatarBadgeEl.textContent = badgeNumber; avatarBadgeEl.classList.remove('hidden'); } diff --git a/app/assets/javascripts/image_diff/helpers/dom_helper.js b/app/assets/javascripts/image_diff/helpers/dom_helper.js index a61e5f01f9b..3468a629f5a 100644 --- a/app/assets/javascripts/image_diff/helpers/dom_helper.js +++ b/app/assets/javascripts/image_diff/helpers/dom_helper.js @@ -10,12 +10,12 @@ export function setPositionDataAttribute(el, options) { } export function updateDiscussionAvatarBadgeNumber(discussionEl, newBadgeNumber) { - const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .badge'); + const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .design-note-pin'); avatarBadgeEl.textContent = newBadgeNumber; } export function updateDiscussionBadgeNumber(discussionEl, newBadgeNumber) { - const discussionBadgeEl = discussionEl.querySelector('.badge'); + const discussionBadgeEl = discussionEl.querySelector('.design-note-pin'); discussionBadgeEl.textContent = newBadgeNumber; } diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js index a0dd8e6f894..e3ca4327efe 100644 --- a/app/assets/javascripts/image_diff/image_diff.js +++ b/app/assets/javascripts/image_diff/image_diff.js @@ -118,7 +118,7 @@ export default class ImageDiff { removeBadge(event) { const { badgeNumber } = event.detail; const indexToRemove = badgeNumber - 1; - const imageBadgeEls = this.imageFrameEl.querySelectorAll('.badge'); + const imageBadgeEls = this.imageFrameEl.querySelectorAll('.design-note-pin'); if (this.imageBadges.length !== badgeNumber) { // Cascade badges count numbers for (avatar badges + image badges) diff --git a/app/assets/javascripts/image_diff/replaced_image_diff.js b/app/assets/javascripts/image_diff/replaced_image_diff.js index a3d9b8a138a..8b84cc45c21 100644 --- a/app/assets/javascripts/image_diff/replaced_image_diff.js +++ b/app/assets/javascripts/image_diff/replaced_image_diff.js @@ -61,7 +61,7 @@ export default class ReplacedImageDiff extends ImageDiff { this.currentView = newView; // Clear existing badges on new view - const existingBadges = this.imageFrameEl.querySelectorAll('.badge'); + const existingBadges = this.imageFrameEl.querySelectorAll('.design-note-pin'); [...existingBadges].map((badge) => badge.remove()); // Remove existing references to old view image badges diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 37597da3c8e..7a904bdb6ad 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -12,7 +12,7 @@ import { } from '@gitlab/ui'; import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils'; import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; +import { s__, n__ } from '~/locale'; import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; import SeverityToken from '~/sidebar/components/severity/severity.vue'; import Tracking from '~/tracking'; @@ -38,6 +38,8 @@ import { import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; import getIncidents from '../graphql/queries/get_incidents.query.graphql'; +const MAX_VISIBLE_ASSIGNEES = 4; + export default { trackIncidentCreateNewOptions, trackIncidentListViewsOptions, @@ -94,6 +96,7 @@ export default { thAttr: TH_PUBLISHED_TEST_ID, }, ], + MAX_VISIBLE_ASSIGNEES, components: { GlLoadingIcon, GlTable, @@ -295,6 +298,13 @@ export default { errorAlertDismissed() { this.isErrorAlertDismissed = true; }, + assigneesBadgeSrOnlyText(item) { + return n__( + '%d additional assignee', + '%d additional assignees', + item.assignees.nodes.length - MAX_VISIBLE_ASSIGNEES, + ); + }, isValidSlaDueAt, }, }; @@ -391,10 +401,11 @@ export default { <gl-avatars-inline :avatars="item.assignees.nodes" :collapsed="true" - :max-visible="4" + :max-visible="$options.MAX_VISIBLE_ASSIGNEES" :avatar-size="24" badge-tooltip-prop="name" :badge-tooltip-max-chars="100" + :badge-sr-only-text="assigneesBadgeSrOnlyText(item)" > <template #avatar="{ avatar }"> <gl-avatar-link diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index b90658fb13c..004601bc0a3 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -1,7 +1,5 @@ import { s__, __ } from '~/locale'; -export const VALIDATE_INTEGRATION_FORM_EVENT = 'validateIntegrationForm'; - export const integrationLevels = { GROUP: 'group', INSTANCE: 'instance', @@ -26,5 +24,3 @@ export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection s export const settingsTabTitle = __('Settings'); export const overridesTabTitle = s__('Integrations|Projects using custom settings'); - -export const INTEGRATION_FORM_SELECTOR = '.js-integration-settings-form'; diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index 4b0579a5beb..b4ceec22822 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -9,8 +9,6 @@ import { } from '@gitlab/ui'; import { capitalize, lowerCase, isEmpty } from 'lodash'; import { mapGetters } from 'vuex'; -import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants'; -import eventHub from '../event_hub'; export default { name: 'DynamicField', @@ -70,11 +68,15 @@ export default { required: false, default: null, }, + isValidated: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { model: this.value, - validated: false, }; }, computed: { @@ -123,22 +125,13 @@ export default { }; }, valid() { - return !this.required || !isEmpty(this.model) || this.isNonEmptyPassword || !this.validated; + return !this.required || !isEmpty(this.model) || this.isNonEmptyPassword || !this.isValidated; }, }, created() { if (this.isNonEmptyPassword) { this.model = null; } - eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm); - }, - beforeDestroy() { - eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm); - }, - methods: { - validateForm() { - this.validated = true; - }, }, helpHtmlConfig: { ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index c3cc35adfa5..007a384f41e 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -5,16 +5,13 @@ import * as Sentry from '@sentry/browser'; import { mapState, mapActions, mapGetters } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { - VALIDATE_INTEGRATION_FORM_EVENT, I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, I18N_DEFAULT_ERROR_MESSAGE, I18N_SUCCESSFUL_CONNECTION_MESSAGE, - INTEGRATION_FORM_SELECTOR, integrationLevels, } from '~/integrations/constants'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import csrf from '~/lib/utils/csrf'; -import eventHub from '../event_hub'; import { testIntegrationSettings } from '../api'; import ActiveCheckbox from './active_checkbox.vue'; import ConfirmationModal from './confirmation_modal.vue'; @@ -57,6 +54,7 @@ export default { isTesting: false, isSaving: false, isResetting: false, + isValidated: false, }; }, computed: { @@ -83,54 +81,38 @@ export default { disableButtons() { return Boolean(this.isSaving || this.isResetting || this.isTesting); }, - useVueForm() { - return this.glFeatures?.vueIntegrationForm; + form() { + return this.$refs.integrationForm.$el; }, - formContainerProps() { - return this.useVueForm - ? { - ref: 'integrationForm', - method: 'post', - class: 'gl-mb-3 gl-show-field-errors integration-settings-form', - action: this.propsSource.formPath, - novalidate: !this.integrationActive, - } - : {}; - }, - formContainer() { - return this.useVueForm ? GlForm : 'div'; - }, - }, - mounted() { - this.form = this.useVueForm - ? this.$refs.integrationForm.$el - : document.querySelector(INTEGRATION_FORM_SELECTOR); }, methods: { - ...mapActions(['setOverride', 'fetchResetIntegration', 'requestJiraIssueTypes']), + ...mapActions(['setOverride', 'requestJiraIssueTypes']), + setIsValidated() { + this.isValidated = true; + }, onSaveClick() { this.isSaving = true; if (this.integrationActive && !this.form.checkValidity()) { this.isSaving = false; - eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); + this.setIsValidated(); return; } this.form.submit(); }, onTestClick() { - this.isTesting = true; - if (!this.form.checkValidity()) { - eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); + this.setIsValidated(); return; } + this.isTesting = true; + testIntegrationSettings(this.propsSource.testPath, this.getFormData()) .then(({ data: { error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE } }) => { if (error) { - eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); + this.setIsValidated(); this.$toast.show(message); return; } @@ -169,16 +151,6 @@ export default { }, onToggleIntegrationState(integrationActive) { this.integrationActive = integrationActive; - if (!this.form || this.useVueForm) { - return; - } - - // If integration will be active, enable form validation. - if (integrationActive) { - this.form.removeAttribute('novalidate'); - } else { - this.form.setAttribute('novalidate', true); - } }, }, helpHtmlConfig: { @@ -191,17 +163,21 @@ export default { </script> <template> - <component :is="formContainer" v-bind="formContainerProps"> - <template v-if="useVueForm"> - <input type="hidden" name="_method" value="put" /> - <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> - <input - type="hidden" - name="redirect_to" - :value="propsSource.redirectTo" - data-testid="redirect-to-field" - /> - </template> + <gl-form + ref="integrationForm" + method="post" + class="gl-mb-3 gl-show-field-errors integration-settings-form" + :action="propsSource.formPath" + :novalidate="!integrationActive" + > + <input type="hidden" name="_method" value="put" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + <input + type="hidden" + name="redirect_to" + :value="propsSource.redirectTo" + data-testid="redirect-to-field" + /> <override-dropdown v-if="defaultState !== null" @@ -227,6 +203,7 @@ export default { v-if="isJira" :key="`${currentKey}-jira-trigger-fields`" v-bind="propsSource.triggerFieldsProps" + :is-validated="isValidated" /> <trigger-fields v-else-if="propsSource.triggerEvents.length" @@ -238,11 +215,13 @@ export default { v-for="field in propsSource.fields" :key="`${currentKey}-${field.name}`" v-bind="field" + :is-validated="isValidated" /> <jira-issues-fields v-if="isJira && !isInstanceOrGroupLevel" :key="`${currentKey}-jira-issues-fields`" v-bind="propsSource.jiraIssuesProps" + :is-validated="isValidated" @request-jira-issue-types="onRequestJiraIssueTypes" /> @@ -311,5 +290,5 @@ export default { </div> </div> </div> - </component> + </gl-form> </template> diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index 99498501f6c..7f2f7620a86 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -1,9 +1,7 @@ <script> import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui'; import { mapGetters } from 'vuex'; -import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants'; import { s__, __ } from '~/locale'; -import eventHub from '../event_hub'; import JiraUpgradeCta from './jira_upgrade_cta.vue'; export default { @@ -64,29 +62,22 @@ export default { required: false, default: '', }, + isValidated: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { enableJiraIssues: this.initialEnableJiraIssues, projectKey: this.initialProjectKey, - validated: false, }; }, computed: { ...mapGetters(['isInheriting']), validProjectKey() { - return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated; - }, - }, - created() { - eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm); - }, - beforeDestroy() { - eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm); - }, - methods: { - validateForm() { - this.validated = true; + return !this.enableJiraIssues || Boolean(this.projectKey) || !this.isValidated; }, }, i18n: { diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index 249a3e105b1..df5946b814a 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -9,9 +9,7 @@ import { } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants'; import { s__ } from '~/locale'; -import eventHub from '../event_hub'; const commentDetailOptions = [ { @@ -92,10 +90,14 @@ export default { required: false, default: '', }, + isValidated: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { - validated: false, triggerCommit: this.initialTriggerCommit, triggerMergeRequest: this.initialTriggerMergeRequest, enableComments: this.initialEnableComments, @@ -115,19 +117,10 @@ export default { return this.triggerCommit || this.triggerMergeRequest; }, validIssueTransitionId() { - return !this.validated || Boolean(this.jiraIssueTransitionId); + return !this.isValidated || Boolean(this.jiraIssueTransitionId); }, }, - created() { - eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm); - }, - beforeDestroy() { - eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm); - }, methods: { - validateForm() { - this.validated = true; - }, showCustomIssueTransitions(currentOption) { return ( this.jiraIssueTransitionAutomatic === ISSUE_TRANSITION_CUSTOM && diff --git a/app/assets/javascripts/integrations/edit/event_hub.js b/app/assets/javascripts/integrations/edit/event_hub.js deleted file mode 100644 index e31806ad199..00000000000 --- a/app/assets/javascripts/integrations/edit/event_hub.js +++ /dev/null @@ -1,3 +0,0 @@ -import createEventHub from '~/helpers/event_hub_factory'; - -export default createEventHub(); diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js index 1398b710d1d..d31d3eb9d82 100644 --- a/app/assets/javascripts/integrations/edit/store/actions.js +++ b/app/assets/javascripts/integrations/edit/store/actions.js @@ -1,10 +1,8 @@ import { - VALIDATE_INTEGRATION_FORM_EVENT, I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, I18N_DEFAULT_ERROR_MESSAGE, } from '~/integrations/constants'; import { testIntegrationSettings } from '../api'; -import eventHub from '../event_hub'; import * as types from './mutation_types'; export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override); @@ -19,7 +17,6 @@ export const requestJiraIssueTypes = ({ commit, dispatch, getters }, formData) = data: { issuetypes, error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE }, }) => { if (error || !issuetypes?.length) { - eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); throw new Error(message); } diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js index ddf6bef7554..eb74b0b1c73 100644 --- a/app/assets/javascripts/integrations/edit/store/mutation_types.js +++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js @@ -1,9 +1,5 @@ export const SET_OVERRIDE = 'SET_OVERRIDE'; -export const SET_IS_RESETTING = 'SET_IS_RESETTING'; export const SET_IS_LOADING_JIRA_ISSUE_TYPES = 'SET_IS_LOADING_JIRA_ISSUE_TYPES'; export const SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE = 'SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE'; export const SET_JIRA_ISSUE_TYPES = 'SET_JIRA_ISSUE_TYPES'; - -export const REQUEST_RESET_INTEGRATION = 'REQUEST_RESET_INTEGRATION'; -export const RECEIVE_RESET_INTEGRATION_ERROR = 'RECEIVE_RESET_INTEGRATION_ERROR'; diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue index 216078ed35e..04a8ec3400f 100644 --- a/app/assets/javascripts/invite_members/components/group_select.vue +++ b/app/assets/javascripts/invite_members/components/group_select.vue @@ -24,6 +24,10 @@ export default { prop: 'selectedGroup', }, props: { + accessLevels: { + type: Object, + required: true, + }, groupsFilter: { type: String, required: false, @@ -34,6 +38,10 @@ export default { required: false, default: null, }, + invalidGroups: { + type: Array, + required: true, + }, }, data() { return { @@ -50,6 +58,13 @@ export default { isFetchResultEmpty() { return this.groups.length === 0; }, + defaultFetchOptions() { + return { + exclude_internal: true, + active: true, + min_access_level: this.accessLevels.Guest, + }; + }, }, watch: { searchTerm() { @@ -64,18 +79,26 @@ export default { this.isFetching = true; return this.fetchGroups() .then((response) => { - this.groups = response.map((group) => ({ - id: group.id, - name: group.full_name, - path: group.path, - avatarUrl: group.avatar_url, - })); + this.groups = this.processGroups(response); this.isFetching = false; }) .catch(() => { this.isFetching = false; }); }, SEARCH_DELAY), + processGroups(response) { + const rawGroups = response.map((group) => ({ + id: group.id, + name: group.full_name, + path: group.path, + avatarUrl: group.avatar_url, + })); + + return this.filterOutInvalidGroups(rawGroups); + }, + filterOutInvalidGroups(groups) { + return groups.filter((group) => this.invalidGroups.indexOf(group.id) === -1); + }, selectGroup(group) { this.selectedGroup = group; @@ -84,13 +107,9 @@ export default { fetchGroups() { switch (this.groupsFilter) { case GROUP_FILTERS.DESCENDANT_GROUPS: - return getDescendentGroups( - this.parentGroupId, - this.searchTerm, - this.$options.defaultFetchOptions, - ); + return getDescendentGroups(this.parentGroupId, this.searchTerm, this.defaultFetchOptions); default: - return getGroups(this.searchTerm, this.$options.defaultFetchOptions); + return getGroups(this.searchTerm, this.defaultFetchOptions); } }, }, @@ -99,10 +118,6 @@ export default { searchPlaceholder: s__('GroupSelect|Search groups'), emptySearchResult: s__('GroupSelect|No matching results'), }, - defaultFetchOptions: { - exclude_internal: true, - active: true, - }, }; </script> <template> diff --git a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue index c9de078319a..c08a4d75c59 100644 --- a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue @@ -21,7 +21,7 @@ export default { }, methods: { openModal() { - eventHub.$emit('openModal', { inviteeType: 'group' }); + eventHub.$emit('openGroupModal'); }, }, }; diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue new file mode 100644 index 00000000000..6598000c464 --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue @@ -0,0 +1,146 @@ +<script> +import { uniqueId } from 'lodash'; +import Api from '~/api'; +import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; +import { GROUP_FILTERS, GROUP_MODAL_LABELS } from '../constants'; +import eventHub from '../event_hub'; +import GroupSelect from './group_select.vue'; +import InviteModalBase from './invite_modal_base.vue'; + +export default { + name: 'InviteMembersModal', + components: { + GroupSelect, + InviteModalBase, + }, + props: { + id: { + type: String, + required: true, + }, + isProject: { + type: Boolean, + required: true, + }, + name: { + type: String, + required: true, + }, + accessLevels: { + type: Object, + required: true, + }, + defaultAccessLevel: { + type: Number, + required: true, + }, + helpLink: { + type: String, + required: true, + }, + groupSelectFilter: { + type: String, + required: false, + default: GROUP_FILTERS.ALL, + }, + groupSelectParentId: { + type: Number, + required: false, + default: null, + }, + invalidGroups: { + type: Array, + required: true, + }, + }, + data() { + return { + modalId: uniqueId('invite-groups-modal-'), + groupToBeSharedWith: {}, + }; + }, + computed: { + labelIntroText() { + return this.$options.labels[this.inviteTo].introText; + }, + inviteTo() { + return this.isProject ? 'toProject' : 'toGroup'; + }, + toastOptions() { + return { + onComplete: () => { + this.groupToBeSharedWith = {}; + }, + }; + }, + inviteDisabled() { + return Object.keys(this.groupToBeSharedWith).length === 0; + }, + }, + mounted() { + eventHub.$on('openGroupModal', () => { + this.openModal(); + }); + }, + methods: { + openModal() { + this.$root.$emit(BV_SHOW_MODAL, this.modalId); + }, + closeModal() { + this.$root.$emit(BV_HIDE_MODAL, this.modalId); + }, + sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) { + const apiShareWithGroup = this.isProject + ? Api.projectShareWithGroup.bind(Api) + : Api.groupShareWithGroup.bind(Api); + + apiShareWithGroup(this.id, { + format: 'json', + group_id: this.groupToBeSharedWith.id, + group_access: accessLevel, + expires_at: expiresAt, + }) + .then(() => { + onSuccess(); + this.showSuccessMessage(); + }) + .catch(onError); + }, + resetFields() { + this.groupToBeSharedWith = {}; + }, + showSuccessMessage() { + this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); + this.closeModal(); + }, + }, + labels: GROUP_MODAL_LABELS, +}; +</script> +<template> + <invite-modal-base + :modal-id="modalId" + :modal-title="$options.labels.title" + :name="name" + :access-levels="accessLevels" + :default-access-level="defaultAccessLevel" + :help-link="helpLink" + v-bind="$attrs" + :label-intro-text="labelIntroText" + :label-search-field="$options.labels.searchField" + :submit-disabled="inviteDisabled" + @reset="resetFields" + @submit="sendInvite" + > + <template #select="{ clearValidation }"> + <group-select + v-model="groupToBeSharedWith" + :access-levels="accessLevels" + :groups-filter="groupSelectFilter" + :parent-group-id="groupSelectParentId" + :invalid-groups="invalidGroups" + @input="clearValidation" + /> + </template> + </invite-modal-base> +</template> diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index 91a139a5105..6c0fc5caf26 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -1,56 +1,40 @@ <script> import { GlAlert, - GlFormGroup, - GlModal, GlDropdown, GlDropdownItem, - GlDatepicker, GlLink, GlSprintf, - GlButton, - GlFormInput, GlFormCheckboxGroup, } from '@gitlab/ui'; -import { partition, isString, unescape, uniqueId } from 'lodash'; +import { partition, isString, uniqueId } from 'lodash'; +import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue'; import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; -import { sanitize } from '~/lib/dompurify'; -import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { getParameterValues } from '~/lib/utils/url_utility'; -import { sprintf } from '~/locale'; import { - GROUP_FILTERS, USERS_FILTER_ALL, INVITE_MEMBERS_FOR_TASK, - MODAL_LABELS, + MEMBER_MODAL_LABELS, LEARN_GITLAB, } from '../constants'; import eventHub from '../event_hub'; -import { - responseMessageFromError, - responseMessageFromSuccess, -} from '../utils/response_message_parser'; +import { responseMessageFromSuccess } from '../utils/response_message_parser'; import ModalConfetti from './confetti.vue'; -import GroupSelect from './group_select.vue'; import MembersTokenSelect from './members_token_select.vue'; export default { name: 'InviteMembersModal', components: { GlAlert, - GlFormGroup, - GlDatepicker, GlLink, - GlModal, GlDropdown, GlDropdownItem, GlSprintf, - GlButton, - GlFormInput, GlFormCheckboxGroup, + InviteModalBase, MembersTokenSelect, - GroupSelect, ModalConfetti, }, inject: ['newProjectPath'], @@ -75,15 +59,9 @@ export default { type: Number, required: true, }, - groupSelectFilter: { + helpLink: { type: String, - required: false, - default: GROUP_FILTERS.ALL, - }, - groupSelectParentId: { - type: Number, - required: false, - default: null, + required: true, }, usersFilter: { type: String, @@ -95,10 +73,6 @@ export default { required: false, default: null, }, - helpLink: { - type: String, - required: true, - }, tasksToBeDoneOptions: { type: Array, required: true, @@ -110,73 +84,31 @@ export default { }, data() { return { - visible: true, modalId: uniqueId('invite-members-modal-'), - selectedAccessLevel: this.defaultAccessLevel, - inviteeType: 'members', newUsersToInvite: [], - selectedDate: undefined, selectedTasksToBeDone: [], selectedTaskProject: this.projects[0], - groupToBeSharedWith: {}, source: 'unknown', - invalidFeedbackMessage: '', - isLoading: false, mode: 'default', + // Kept in sync with "base" + selectedAccessLevel: undefined, }; }, computed: { isCelebration() { return this.mode === 'celebrate'; }, - validationState() { - return this.invalidFeedbackMessage === '' ? null : false; - }, - isInviteGroup() { - return this.inviteeType === 'group'; - }, modalTitle() { - return this.$options.labels[this.inviteeType].modal[this.mode].title; - }, - introText() { - return sprintf(this.$options.labels[this.inviteeType][this.inviteTo][this.mode].introText, { - name: this.name, - }); + return this.$options.labels.modal[this.mode].title; }, inviteTo() { return this.isProject ? 'toProject' : 'toGroup'; }, - toastOptions() { - return { - onComplete: () => { - this.selectedAccessLevel = this.defaultAccessLevel; - this.newUsersToInvite = []; - this.groupToBeSharedWith = {}; - }, - }; - }, - basePostData() { - return { - expires_at: this.selectedDate, - format: 'json', - }; - }, - selectedRoleName() { - return Object.keys(this.accessLevels).find( - (key) => this.accessLevels[key] === Number(this.selectedAccessLevel), - ); + labelIntroText() { + return this.$options.labels[this.inviteTo][this.mode].introText; }, inviteDisabled() { - return ( - this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0 - ); - }, - errorFieldDescription() { - if (this.inviteeType === 'group') { - return ''; - } - - return this.$options.labels[this.inviteeType].placeHolder; + return this.newUsersToInvite.length === 0; }, tasksToBeDoneEnabled() { return ( @@ -215,7 +147,7 @@ export default { }); if (this.tasksToBeDoneEnabled) { - this.openModal({ inviteeType: 'members', source: 'in_product_marketing_email' }); + this.openModal({ source: 'in_product_marketing_email' }); this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view); } }, @@ -231,72 +163,42 @@ export default { usersToAddById.map((user) => user.id).join(','), ]; }, - openModal({ mode = 'default', inviteeType, source }) { + openModal({ mode = 'default', source }) { this.mode = mode; - this.inviteeType = inviteeType; this.source = source; this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, + closeModal() { + this.$root.$emit(BV_HIDE_MODAL, this.modalId); + }, trackEvent(experimentName, eventName) { const tracking = new ExperimentTracking(experimentName); tracking.event(eventName); }, - closeModal() { - this.resetFields(); - this.$refs.modal.hide(); - }, - sendInvite() { - if (this.isInviteGroup) { - this.submitShareWithGroup(); - } else { - this.submitInviteMembers(); - } - }, - trackinviteMembersForTask() { - const label = 'selected_tasks_to_be_done'; - const property = this.selectedTasksToBeDone.join(','); - const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property }); - tracking.event(INVITE_MEMBERS_FOR_TASK.submit); - }, - resetFields() { - this.isLoading = false; - this.selectedAccessLevel = this.defaultAccessLevel; - this.selectedDate = undefined; - this.newUsersToInvite = []; - this.groupToBeSharedWith = {}; - this.invalidFeedbackMessage = ''; - this.selectedTasksToBeDone = []; - [this.selectedTaskProject] = this.projects; - }, - changeSelectedItem(item) { - this.selectedAccessLevel = item; - }, - changeSelectedTaskProject(project) { - this.selectedTaskProject = project; - }, - submitShareWithGroup() { - const apiShareWithGroup = this.isProject - ? Api.projectShareWithGroup.bind(Api) - : Api.groupShareWithGroup.bind(Api); - - apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id)) - .then(this.showSuccessMessage) - .catch(this.showInvalidFeedbackMessage); - }, - submitInviteMembers() { - this.invalidFeedbackMessage = ''; - this.isLoading = true; - + sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) { const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); const promises = []; + const baseData = { + format: 'json', + expires_at: expiresAt, + access_level: accessLevel, + invite_source: this.source, + tasks_to_be_done: this.tasksToBeDoneForPost, + tasks_project_id: this.tasksProjectForPost, + }; if (usersToInviteByEmail !== '') { const apiInviteByEmail = this.isProject ? Api.inviteProjectMembersByEmail.bind(Api) : Api.inviteGroupMembersByEmail.bind(Api); - promises.push(apiInviteByEmail(this.id, this.inviteByEmailPostData(usersToInviteByEmail))); + promises.push( + apiInviteByEmail(this.id, { + ...baseData, + email: usersToInviteByEmail, + }), + ); } if (usersToAddById !== '') { @@ -304,188 +206,103 @@ export default { ? Api.addProjectMembersByUserId.bind(Api) : Api.addGroupMembersByUserId.bind(Api); - promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById))); + promises.push( + apiAddByUserId(this.id, { + ...baseData, + user_id: usersToAddById, + }), + ); } this.trackinviteMembersForTask(); Promise.all(promises) - .then(this.conditionallyShowSuccessMessage) - .catch(this.showInvalidFeedbackMessage); - }, - inviteByEmailPostData(usersToInviteByEmail) { - return { - ...this.basePostData, - email: usersToInviteByEmail, - access_level: this.selectedAccessLevel, - invite_source: this.source, - tasks_to_be_done: this.tasksToBeDoneForPost, - tasks_project_id: this.tasksProjectForPost, - }; + .then((responses) => { + const message = responseMessageFromSuccess(responses); + + if (message) { + onError({ + response: { + data: { + message, + }, + }, + }); + } else { + onSuccess(); + this.showSuccessMessage(); + } + }) + .catch(onError); }, - addByUserIdPostData(usersToAddById) { - return { - ...this.basePostData, - user_id: usersToAddById, - access_level: this.selectedAccessLevel, - invite_source: this.source, - tasks_to_be_done: this.tasksToBeDoneForPost, - tasks_project_id: this.tasksProjectForPost, - }; + trackinviteMembersForTask() { + const label = 'selected_tasks_to_be_done'; + const property = this.selectedTasksToBeDone.join(','); + const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property }); + tracking.event(INVITE_MEMBERS_FOR_TASK.submit); }, - shareWithGroupPostData(groupToBeSharedWith) { - return { - ...this.basePostData, - group_id: groupToBeSharedWith, - group_access: this.selectedAccessLevel, - }; + resetFields() { + this.newUsersToInvite = []; + this.selectedTasksToBeDone = []; + [this.selectedTaskProject] = this.projects; }, - conditionallyShowSuccessMessage(response) { - const message = this.unescapeMsg(responseMessageFromSuccess(response)); - - if (message === '') { - this.showSuccessMessage(); - - return; - } - - this.invalidFeedbackMessage = message; - this.isLoading = false; + changeSelectedTaskProject(project) { + this.selectedTaskProject = project; }, showSuccessMessage() { if (this.isOnLearnGitlab) { eventHub.$emit('showSuccessfulInvitationsAlert'); } else { - this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); + this.$toast.show(this.$options.labels.toastMessageSuccessful); } - this.closeModal(); - }, - showInvalidFeedbackMessage(response) { - const message = this.unescapeMsg(responseMessageFromError(response)); - this.isLoading = false; - this.invalidFeedbackMessage = message || this.$options.labels.invalidFeedbackMessageDefault; - }, - handleMembersTokenSelectClear() { - this.invalidFeedbackMessage = ''; + this.closeModal(); }, - unescapeMsg(message) { - return unescape(sanitize(message, { ALLOWED_TAGS: [] })); + onAccessLevelUpdate(val) { + this.selectedAccessLevel = val; }, }, - labels: MODAL_LABELS, - membersTokenSelectLabelId: 'invite-members-input', + labels: MEMBER_MODAL_LABELS, }; </script> <template> - <gl-modal - ref="modal" + <invite-modal-base :modal-id="modalId" - size="sm" - data-qa-selector="invite_members_modal_content" - data-testid="invite-members-modal" - :title="modalTitle" - :header-close-label="$options.labels.headerCloseLabel" - @hidden="resetFields" - @close="resetFields" - @hide="resetFields" + :modal-title="modalTitle" + :name="name" + :access-levels="accessLevels" + :default-access-level="defaultAccessLevel" + :help-link="helpLink" + :label-intro-text="labelIntroText" + :label-search-field="$options.labels.searchField" + :form-group-description="$options.labels.placeHolder" + :submit-disabled="inviteDisabled" + @reset="resetFields" + @submit="sendInvite" + @access-level="onAccessLevelUpdate" > - <div> - <div class="gl-display-flex"> - <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div> - <div> - <p ref="introText"> - <gl-sprintf :message="introText"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - <br /> - <span v-if="isCelebration">{{ $options.labels.members.modal.celebrate.intro }} </span> - <modal-confetti v-if="isCelebration" /> - </p> - </div> - </div> - - <gl-form-group - :invalid-feedback="invalidFeedbackMessage" - :state="validationState" - :description="errorFieldDescription" - data-testid="members-form-group" - > - <label :id="$options.membersTokenSelectLabelId" class="col-form-label">{{ - $options.labels[inviteeType].searchField - }}</label> - <members-token-select - v-if="!isInviteGroup" - v-model="newUsersToInvite" - class="gl-mb-2" - :validation-state="validationState" - :aria-labelledby="$options.membersTokenSelectLabelId" - :users-filter="usersFilter" - :filter-id="filterId" - @clear="handleMembersTokenSelectClear" - /> - <group-select - v-if="isInviteGroup" - v-model="groupToBeSharedWith" - :groups-filter="groupSelectFilter" - :parent-group-id="groupSelectParentId" - @input="handleMembersTokenSelectClear" - /> - </gl-form-group> - - <label class="gl-font-weight-bold">{{ $options.labels.accessLevel }}</label> - <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-dropdown - class="gl-shadow-none gl-w-full" - data-qa-selector="access_level_dropdown" - v-bind="$attrs" - :text="selectedRoleName" - > - <template v-for="(key, item) in accessLevels"> - <gl-dropdown-item - :key="key" - active-class="is-active" - is-check-item - :is-checked="key === selectedAccessLevel" - @click="changeSelectedItem(key)" - > - <div>{{ item }}</div> - </gl-dropdown-item> - </template> - </gl-dropdown> - </div> - - <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <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-mt-5 gl-display-block" 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> + <template #intro-text-before> + <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div> + </template> + <template #intro-text-after> + <br /> + <span v-if="isCelebration">{{ $options.labels.modal.celebrate.intro }} </span> + <modal-confetti v-if="isCelebration" /> + </template> + <template #select="{ clearValidation, validationState, labelId }"> + <members-token-select + v-model="newUsersToInvite" + class="gl-mb-2" + :validation-state="validationState" + :aria-labelledby="labelId" + :users-filter="usersFilter" + :filter-id="filterId" + @clear="clearValidation" + /> + </template> + <template #form-after> <div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done"> <label class="gl-mt-5"> - {{ $options.labels.members.tasksToBeDone.title }} + {{ $options.labels.tasksToBeDone.title }} </label> <template v-if="projects.length"> <gl-form-checkbox-group @@ -495,7 +312,7 @@ export default { /> <template v-if="showTaskProjects"> <label class="gl-mt-5 gl-display-block"> - {{ $options.labels.members.tasksProject.title }} + {{ $options.labels.tasksProject.title }} </label> <gl-dropdown class="gl-w-half gl-xs-w-full" @@ -522,7 +339,7 @@ export default { :dismissible="false" data-testid="invite-members-modal-no-projects-alert" > - <gl-sprintf :message="$options.labels.members.tasksToBeDone.noProjects"> + <gl-sprintf :message="$options.labels.tasksToBeDone.noProjects"> <template #link="{ content }"> <gl-link :href="newProjectPath" target="_blank" class="gl-label-link"> {{ content }} @@ -531,22 +348,6 @@ export default { </gl-sprintf> </gl-alert> </div> - </div> - - <template #modal-footer> - <gl-button data-testid="cancel-button" @click="closeModal"> - {{ $options.labels.cancelButtonText }} - </gl-button> - <gl-button - :disabled="inviteDisabled" - :loading="isLoading" - variant="success" - data-qa-selector="invite_button" - data-testid="invite-button" - @click="sendInvite" - > - {{ $options.labels.inviteButtonText }} - </gl-button> </template> - </gl-modal> + </invite-modal-base> </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 index 7dd74f8803a..79b192e2495 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -71,7 +71,7 @@ export default { return this.triggerElement === targetTriggerElement; }, openModal() { - eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource }); + eventHub.$emit('openModal', { source: this.triggerSource }); }, }, TRIGGER_ELEMENT_BUTTON, diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue new file mode 100644 index 00000000000..fc00f5b9343 --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -0,0 +1,276 @@ +<script> +import { + GlFormGroup, + GlModal, + GlDropdown, + GlDropdownItem, + GlDatepicker, + GlLink, + GlSprintf, + GlButton, + GlFormInput, +} from '@gitlab/ui'; +import { unescape } from 'lodash'; +import { sanitize } from '~/lib/dompurify'; +import { sprintf } from '~/locale'; +import { + ACCESS_LEVEL, + ACCESS_EXPIRE_DATE, + INVALID_FEEDBACK_MESSAGE_DEFAULT, + READ_MORE_TEXT, + INVITE_BUTTON_TEXT, + CANCEL_BUTTON_TEXT, + HEADER_CLOSE_LABEL, +} from '../constants'; +import { responseMessageFromError } from '../utils/response_message_parser'; + +export default { + components: { + GlFormGroup, + GlDatepicker, + GlLink, + GlModal, + GlDropdown, + GlDropdownItem, + GlSprintf, + GlButton, + GlFormInput, + }, + inheritAttrs: false, + props: { + modalTitle: { + type: String, + required: true, + }, + modalId: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + accessLevels: { + type: Object, + required: true, + }, + defaultAccessLevel: { + type: Number, + required: true, + }, + helpLink: { + type: String, + required: true, + }, + labelIntroText: { + type: String, + required: true, + }, + labelSearchField: { + type: String, + required: true, + }, + formGroupDescription: { + type: String, + required: false, + default: '', + }, + submitDisabled: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + // Be sure to check out reset! + return { + invalidFeedbackMessage: '', + selectedAccessLevel: this.defaultAccessLevel, + selectedDate: undefined, + isLoading: false, + minDate: new Date(), + }; + }, + computed: { + introText() { + return sprintf(this.labelIntroText, { name: this.name }); + }, + validationState() { + return this.invalidFeedbackMessage ? false : null; + }, + selectLabelId() { + return `${this.modalId}_select`; + }, + selectedRoleName() { + return Object.keys(this.accessLevels).find( + (key) => this.accessLevels[key] === Number(this.selectedAccessLevel), + ); + }, + }, + watch: { + selectedAccessLevel: { + immediate: true, + handler(val) { + this.$emit('access-level', val); + }, + }, + }, + methods: { + showInvalidFeedbackMessage(response) { + const message = this.unescapeMsg(responseMessageFromError(response)); + + this.invalidFeedbackMessage = message || INVALID_FEEDBACK_MESSAGE_DEFAULT; + }, + reset() { + // This component isn't necessarily disposed, + // so we might need to reset it's state. + this.isLoading = false; + this.invalidFeedbackMessage = ''; + this.selectedAccessLevel = this.defaultAccessLevel; + this.selectedDate = undefined; + + this.$emit('reset'); + }, + closeModal() { + this.reset(); + this.$refs.modal.hide(); + }, + clearValidation() { + this.invalidFeedbackMessage = ''; + }, + changeSelectedItem(item) { + this.selectedAccessLevel = item; + }, + submit() { + this.isLoading = true; + this.invalidFeedbackMessage = ''; + + this.$emit('submit', { + onSuccess: () => { + this.isLoading = false; + }, + onError: (...args) => { + this.isLoading = false; + this.showInvalidFeedbackMessage(...args); + }, + data: { + accessLevel: this.selectedAccessLevel, + expiresAt: this.selectedDate, + }, + }); + }, + unescapeMsg(message) { + return unescape(sanitize(message, { ALLOWED_TAGS: [] })); + }, + }, + HEADER_CLOSE_LABEL, + ACCESS_EXPIRE_DATE, + ACCESS_LEVEL, + READ_MORE_TEXT, + INVITE_BUTTON_TEXT, + CANCEL_BUTTON_TEXT, +}; +</script> + +<template> + <gl-modal + ref="modal" + :modal-id="modalId" + data-qa-selector="invite_members_modal_content" + data-testid="invite-modal" + size="sm" + :title="modalTitle" + :header-close-label="$options.HEADER_CLOSE_LABEL" + @hidden="reset" + @close="reset" + @hide="reset" + > + <div class="gl-display-flex" data-testid="modal-base-intro-text"> + <slot name="intro-text-before"></slot> + <p> + <gl-sprintf :message="introText"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + <slot name="intro-text-after"></slot> + </div> + + <gl-form-group + :invalid-feedback="invalidFeedbackMessage" + :state="validationState" + :description="formGroupDescription" + data-testid="members-form-group" + > + <label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label> + <slot + name="select" + v-bind="{ clearValidation, validationState, labelId: selectLabelId }" + ></slot> + </gl-form-group> + + <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label> + <div class="gl-mt-2 gl-w-half gl-xs-w-full"> + <gl-dropdown + class="gl-shadow-none gl-w-full" + data-qa-selector="access_level_dropdown" + v-bind="$attrs" + :text="selectedRoleName" + > + <template v-for="(key, item) in accessLevels"> + <gl-dropdown-item + :key="key" + active-class="is-active" + is-check-item + :is-checked="key === selectedAccessLevel" + @click="changeSelectedItem(key)" + > + <div>{{ item }}</div> + </gl-dropdown-item> + </template> + </gl-dropdown> + </div> + + <div class="gl-mt-2 gl-w-half gl-xs-w-full"> + <gl-sprintf :message="$options.READ_MORE_TEXT"> + <template #link="{ content }"> + <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + + <label class="gl-mt-5 gl-display-block" for="expires_at">{{ + $options.ACCESS_EXPIRE_DATE + }}</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="minDate" + :target="null" + > + <template #default="{ formattedDate }"> + <gl-form-input class="gl-w-full" :value="formattedDate" :placeholder="__(`YYYY-MM-DD`)" /> + </template> + </gl-datepicker> + </div> + <slot name="form-after"></slot> + + <template #modal-footer> + <gl-button data-testid="cancel-button" @click="closeModal"> + {{ $options.CANCEL_BUTTON_TEXT }} + </gl-button> + <gl-button + :disabled="submitDisabled" + :loading="isLoading" + variant="success" + data-qa-selector="invite_button" + data-testid="invite-button" + @click="submit" + > + {{ $options.INVITE_BUTTON_TEXT }} + </gl-button> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index ec59b3909fe..cf2ee508184 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -72,67 +72,52 @@ export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite'); export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel'); export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members'); -export const MODAL_LABELS = { - members: { - modal: { - default: { - title: MEMBERS_MODAL_DEFAULT_TITLE, - }, - celebrate: { - title: MEMBERS_MODAL_CELEBRATE_TITLE, - intro: MEMBERS_MODAL_CELEBRATE_INTRO, - }, +export const MEMBER_MODAL_LABELS = { + modal: { + default: { + title: MEMBERS_MODAL_DEFAULT_TITLE, }, - toGroup: { - default: { - introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT, - }, - }, - toProject: { - default: { - introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT, - }, - celebrate: { - introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, - }, - }, - searchField: MEMBERS_SEARCH_FIELD, - placeHolder: MEMBERS_PLACEHOLDER, - tasksToBeDone: { - title: MEMBERS_TASKS_TO_BE_DONE_TITLE, - noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS, - }, - tasksProject: { - title: MEMBERS_TASKS_PROJECTS_TITLE, + celebrate: { + title: MEMBERS_MODAL_CELEBRATE_TITLE, + intro: MEMBERS_MODAL_CELEBRATE_INTRO, }, }, - group: { - modal: { - default: { - title: GROUP_MODAL_DEFAULT_TITLE, - }, + toGroup: { + default: { + introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT, }, - toGroup: { - default: { - introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT, - }, + }, + toProject: { + default: { + introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT, }, - toProject: { - default: { - introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT, - }, + celebrate: { + introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, }, - searchField: GROUP_SEARCH_FIELD, - placeHolder: GROUP_PLACEHOLDER, }, - accessLevel: ACCESS_LEVEL, - accessExpireDate: ACCESS_EXPIRE_DATE, + searchField: MEMBERS_SEARCH_FIELD, + placeHolder: MEMBERS_PLACEHOLDER, + tasksToBeDone: { + title: MEMBERS_TASKS_TO_BE_DONE_TITLE, + noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS, + }, + tasksProject: { + title: MEMBERS_TASKS_PROJECTS_TITLE, + }, + toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL, +}; + +export const GROUP_MODAL_LABELS = { + title: GROUP_MODAL_DEFAULT_TITLE, + toGroup: { + introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT, + }, + toProject: { + introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT, + }, + searchField: GROUP_SEARCH_FIELD, + placeHolder: GROUP_PLACEHOLDER, toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL, - invalidFeedbackMessageDefault: INVALID_FEEDBACK_MESSAGE_DEFAULT, - readMoreText: READ_MORE_TEXT, - inviteButtonText: INVITE_BUTTON_TEXT, - cancelButtonText: CANCEL_BUTTON_TEXT, - headerCloseLabel: HEADER_CLOSE_LABEL, }; export const LEARN_GITLAB = 'learn_gitlab'; diff --git a/app/assets/javascripts/invite_members/init_invite_groups_modal.js b/app/assets/javascripts/invite_members/init_invite_groups_modal.js new file mode 100644 index 00000000000..be1576ad0b0 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_invite_groups_modal.js @@ -0,0 +1,44 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import InviteGroupsModal from '~/invite_members/components/invite_groups_modal.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; + +Vue.use(GlToast); + +let initedInviteGroupsModal; + +export default function initInviteGroupsModal() { + if (initedInviteGroupsModal) { + // if we already loaded this in another part of the dom, we don't want to do it again + // else we will stack the modals + return false; + } + + // https://gitlab.com/gitlab-org/gitlab/-/issues/344955 + // bug lying in wait here for someone to put group and project invite in same screen + // once that happens we'll need to mount these differently, perhaps split + // group/project to each mount one, with many ways to open it. + const el = document.querySelector('.js-invite-groups-modal'); + + if (!el) { + return false; + } + + initedInviteGroupsModal = true; + + return new Vue({ + el, + render: (createElement) => + createElement(InviteGroupsModal, { + props: { + ...el.dataset, + isProject: parseBoolean(el.dataset.isProject), + accessLevels: JSON.parse(el.dataset.accessLevels), + defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), + groupSelectFilter: el.dataset.groupsFilter, + groupSelectParentId: parseInt(el.dataset.parentId, 10), + invalidGroups: JSON.parse(el.dataset.invalidGroups || '[]'), + }, + }), + }); +} diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index 2cc056f2ddb..e9d620cedf0 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -28,6 +28,7 @@ export default function initInviteMembersModal() { return new Vue({ el, + name: 'InviteMembersModalRoot', provide: { newProjectPath: el.dataset.newProjectPath, }, @@ -38,8 +39,6 @@ export default function initInviteMembersModal() { isProject: parseBoolean(el.dataset.isProject), accessLevels: JSON.parse(el.dataset.accessLevels), defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), - groupSelectFilter: el.dataset.groupsFilter, - groupSelectParentId: parseInt(el.dataset.parentId, 10), tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'), projects: JSON.parse(el.dataset.projects || '[]'), usersFilter: el.dataset.usersFilter, diff --git a/app/assets/javascripts/invite_members/init_invite_members_trigger.js b/app/assets/javascripts/invite_members/init_invite_members_trigger.js index 935edb35349..54a5eab2e4b 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_trigger.js +++ b/app/assets/javascripts/invite_members/init_invite_members_trigger.js @@ -11,6 +11,7 @@ export default function initInviteMembersTrigger() { return triggers.forEach((el) => { return new Vue({ el, + name: 'InviteMembersTriggerRoot', render: (createElement) => createElement(InviteMembersTrigger, { props: { diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js index dca606556d0..967996b859e 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js @@ -23,6 +23,7 @@ export function initIssueStatusSelect() { return new Vue({ el, + name: 'StatusSelectRoot', render: (createElement) => createElement(StatusSelect), }); } diff --git a/app/assets/javascripts/issuable/index.js b/app/assets/javascripts/issuable/index.js index 57bad5182e7..10dbefce503 100644 --- a/app/assets/javascripts/issuable/index.js +++ b/app/assets/javascripts/issuable/index.js @@ -32,6 +32,7 @@ export function initCsvImportExportButtons() { return new Vue({ el, + name: 'CsvImportExportButtonsRoot', provide: { showExportButton: parseBoolean(showExportButton), showImportButton: parseBoolean(showImportButton), @@ -74,6 +75,7 @@ export function initIssuableByEmail() { return new Vue({ el, + name: 'IssuableByEmailRoot', provide: { initialEmail, issuableType, @@ -97,6 +99,7 @@ export function initIssuableHeaderWarnings(store) { return new Vue({ el, + name: 'IssuableHeaderWarningsRoot', store, provide: { hidden: parseBoolean(hidden) }, render: (createElement) => createElement(IssuableHeaderWarnings), diff --git a/app/assets/javascripts/issuable/issuable_context.js b/app/assets/javascripts/issuable/issuable_context.js index 453305dd6e0..37001d00a27 100644 --- a/app/assets/javascripts/issuable/issuable_context.js +++ b/app/assets/javascripts/issuable/issuable_context.js @@ -1,6 +1,6 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; -import Cookies from 'js-cookie'; +import { setCookie } from '~/lib/utils/common_utils'; import { loadCSSFile } from '~/lib/utils/css_utils'; import UsersSelect from '~/users_select'; @@ -62,7 +62,7 @@ export default class IssuableContext { const supportedSizes = ['xs', 'sm', 'md']; if (supportedSizes.includes(bpBreakpoint)) { - Cookies.set('collapsed_gutter', true); + setCookie('collapsed_gutter', true); } }); } diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index 91f47a86cb7..88c1748db0b 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -77,6 +77,7 @@ export default class IssuableForm { this.initAutosave(); this.form.on('submit', this.handleSubmit); this.form.on('click', '.btn-cancel', this.resetAutosave); + this.form.find('.js-unwrap-on-load').unwrap(); this.initWip(); const $issuableDueDate = $('#issuable-due-date'); diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js index 5d36396bc6e..a3752c7043c 100644 --- a/app/assets/javascripts/issues/create_merge_request_dropdown.js +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -69,11 +69,11 @@ export default class CreateMergeRequestDropdown { this.regexps = { branch: { createBranchPath: new RegExp('(branch_name=)(.+?)(?=&issue)'), - createMrPath: new RegExp('(branch_name=)(.+?)(?=&ref)'), + createMrPath: new RegExp('(source_branch%5D=)(.+?)(?=&)'), }, ref: { createBranchPath: new RegExp('(ref=)(.+?)$'), - createMrPath: new RegExp('(ref=)(.+?)$'), + createMrPath: new RegExp('(target_branch%5D=)(.+?)$'), }, }; @@ -167,23 +167,18 @@ export default class CreateMergeRequestDropdown { } createMergeRequest() { - this.isCreatingMergeRequest = true; - - return axios - .post(this.createMrPath, { - target_project_id: canCreateConfidentialMergeRequest() - ? confidentialMergeRequestState.selectedProject.id - : null, - }) - .then(({ data }) => { - this.mergeRequestCreated = true; - window.location.href = data.url; - }) - .catch(() => - createFlash({ - message: __('Failed to create merge request. Please try again.'), - }), - ); + return new Promise(() => { + this.isCreatingMergeRequest = true; + + return this.createBranch().then(() => { + window.location.href = canCreateConfidentialMergeRequest() + ? this.createMrPath.replace( + this.projectPath, + confidentialMergeRequestState.selectedProject.pathWithNamespace, + ) + : this.createMrPath; + }); + }); } disable() { @@ -562,5 +557,7 @@ export default class CreateMergeRequestDropdown { this.regexps[target].createMrPath, pathReplacement, ); + + this.wrapperEl.dataset.createMrPath = this.createMrPath; } } diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index 8b15e801f02..3866a7b3305 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -10,16 +10,30 @@ import { } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import { orderBy } from 'lodash'; +import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; -import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; import createFlash, { FLASH_TYPES } from '~/flash'; import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { ITEM_TYPE } from '~/groups/constants'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; +import axios from '~/lib/utils/axios_utils'; +import { scrollUp } from '~/lib/utils/scroll_utils'; +import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; +import { + DEFAULT_NONE_ANY, + OPERATOR_IS_ONLY, + TOKEN_TITLE_ASSIGNEE, + TOKEN_TITLE_AUTHOR, + TOKEN_TITLE_CONFIDENTIAL, + TOKEN_TITLE_LABEL, + TOKEN_TITLE_MILESTONE, + TOKEN_TITLE_MY_REACTION, + TOKEN_TITLE_RELEASE, + TOKEN_TITLE_TYPE, +} from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; import { @@ -27,8 +41,6 @@ import { i18n, MAX_LIST_SIZE, PAGE_SIZE, - PARAM_DUE_DATE, - PARAM_SORT, PARAM_STATE, RELATIVE_POSITION_ASC, TOKEN_TYPE_ASSIGNEE, @@ -41,37 +53,23 @@ import { TOKEN_TYPE_TYPE, UPDATED_DESC, urlSortParams, -} from '~/issues/list/constants'; +} from '../constants'; +import eventHub from '../eventhub'; +import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql'; +import searchLabelsQuery from '../queries/search_labels.query.graphql'; +import searchMilestonesQuery from '../queries/search_milestones.query.graphql'; +import searchUsersQuery from '../queries/search_users.query.graphql'; +import setSortPreferenceMutation from '../queries/set_sort_preference.mutation.graphql'; import { convertToApiParams, convertToSearchQuery, convertToUrlParams, - getDueDateValue, getFilterTokens, getInitialPageParams, getSortKey, getSortOptions, -} from '~/issues/list/utils'; -import axios from '~/lib/utils/axios_utils'; -import { scrollUp } from '~/lib/utils/scroll_utils'; -import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; -import { - DEFAULT_NONE_ANY, - OPERATOR_IS_ONLY, - TOKEN_TITLE_ASSIGNEE, - TOKEN_TITLE_AUTHOR, - TOKEN_TITLE_CONFIDENTIAL, - TOKEN_TITLE_LABEL, - TOKEN_TITLE_MILESTONE, - TOKEN_TITLE_MY_REACTION, - TOKEN_TITLE_RELEASE, - TOKEN_TITLE_TYPE, -} from '~/vue_shared/components/filtered_search_bar/constants'; -import eventHub from '../eventhub'; -import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql'; -import searchLabelsQuery from '../queries/search_labels.query.graphql'; -import searchMilestonesQuery from '../queries/search_milestones.query.graphql'; -import searchUsersQuery from '../queries/search_users.query.graphql'; + isSortKey, +} from '../utils'; import NewIssueDropdown from './new_issue_dropdown.vue'; const AuthorToken = () => @@ -103,74 +101,31 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - inject: { - autocompleteAwardEmojisPath: { - default: '', - }, - calendarPath: { - default: '', - }, - canBulkUpdate: { - default: false, - }, - emptyStateSvgPath: { - default: '', - }, - exportCsvPath: { - default: '', - }, - fullPath: { - default: '', - }, - hasAnyIssues: { - default: false, - }, - hasAnyProjects: { - default: false, - }, - hasBlockedIssuesFeature: { - default: false, - }, - hasIssueWeightsFeature: { - default: false, - }, - hasMultipleIssueAssigneesFeature: { - default: false, - }, - initialEmail: { - default: '', - }, - isAnonymousSearchDisabled: { - default: false, - }, - isIssueRepositioningDisabled: { - default: false, - }, - isProject: { - default: false, - }, - isSignedIn: { - default: false, - }, - jiraIntegrationPath: { - default: '', - }, - newIssuePath: { - default: '', - }, - releasesPath: { - default: '', - }, - rssPath: { - default: '', - }, - showNewIssueLink: { - default: false, - }, - signInPath: { - default: '', - }, - }, + inject: [ + 'autocompleteAwardEmojisPath', + 'calendarPath', + 'canBulkUpdate', + 'emptyStateSvgPath', + 'exportCsvPath', + 'fullPath', + 'hasAnyIssues', + 'hasAnyProjects', + 'hasBlockedIssuesFeature', + 'hasIssueWeightsFeature', + 'hasMultipleIssueAssigneesFeature', + 'initialEmail', + 'initialSort', + 'isAnonymousSearchDisabled', + 'isIssueRepositioningDisabled', + 'isProject', + 'isSignedIn', + 'jiraIntegrationPath', + 'newIssuePath', + 'releasesPath', + 'rssPath', + 'showNewIssueLink', + 'signInPath', + ], props: { eeSearchTokens: { type: Array, @@ -181,7 +136,13 @@ export default { data() { const state = getParameterByName(PARAM_STATE); const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; - let sortKey = getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey; + const dashboardSortKey = getSortKey(this.initialSort); + const graphQLSortKey = + isSortKey(this.initialSort?.toUpperCase()) && this.initialSort.toUpperCase(); + + // The initial sort is an old enum value when it is saved on the dashboard issues page. + // The initial sort is a GraphQL enum value when it is saved on the Vue issues list page. + let sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey; if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { this.showIssueRepositioningMessage(); @@ -198,7 +159,6 @@ export default { } return { - dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)), exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), filterTokens: isSearchDisabled ? [] : getFilterTokens(window.location.search), issues: [], @@ -221,6 +181,9 @@ export default { return data[this.namespace]?.issues.nodes ?? []; }, result({ data }) { + if (!data) { + return; + } this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {}; this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); }, @@ -341,6 +304,7 @@ export default { token: MilestoneToken, fetchMilestones: this.fetchMilestones, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`, + shouldSkipSort: true, }, { type: TOKEN_TYPE_LABEL, @@ -406,7 +370,7 @@ export default { tokens.sort((a, b) => a.title.localeCompare(b.title)); - return orderBy(tokens, ['title']); + return tokens; }, showPaginationControls() { return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); @@ -427,7 +391,6 @@ export default { }, urlParams() { return { - due_date: this.dueDateFilter, search: this.searchQuery, sort: urlSortParams[this.sortKey], state: this.state, @@ -584,7 +547,6 @@ export default { .put(joinPaths(issueToMove.webPath, 'reorder'), { move_before_id: isMovingToBeginning ? null : getIdFromGraphQLId(moveBeforeId), move_after_id: isMovingToEnd ? null : getIdFromGraphQLId(moveAfterId), - group_full_path: this.isProject ? undefined : this.fullPath, }) .then(() => { const serializedVariables = JSON.stringify(this.queryVariables); @@ -608,6 +570,25 @@ export default { this.pageParams = getInitialPageParams(sortKey); } this.sortKey = sortKey; + + if (this.isSignedIn) { + this.saveSortPreference(sortKey); + } + }, + saveSortPreference(sortKey) { + this.$apollo + .mutate({ + mutation: setSortPreferenceMutation, + variables: { input: { issuesSort: sortKey } }, + }) + .then(({ data }) => { + if (data.userPreferencesUpdate.errors.length) { + throw new Error(data.userPreferencesUpdate.errors); + } + }) + .catch((error) => { + Sentry.captureException(error); + }); }, showAnonymousSearchingMessage() { createFlash({ @@ -644,6 +625,7 @@ export default { :tabs="$options.IssuableListTabs" :current-tab="state" :tab-counts="tabCounts" + :truncate-counts="!isProject" :issuables-loading="$apollo.queries.issues.loading" :is-manual-ordering="isManualOrdering" :show-bulk-edit-sidebar="showBulkEditSidebar" diff --git a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue index 71f84050ba8..666e80dfd4b 100644 --- a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue +++ b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue @@ -7,10 +7,10 @@ import { GlSearchBoxByType, } from '@gitlab/ui'; import createFlash from '~/flash'; -import searchProjectsQuery from '~/issues/list/queries/search_projects.query.graphql'; import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import searchProjectsQuery from '../queries/search_projects.query.graphql'; export default { i18n: { diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 4a380848b4f..284167a933f 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -55,8 +55,6 @@ export const i18n = { export const MAX_LIST_SIZE = 10; export const PAGE_SIZE = 20; export const PAGE_SIZE_MANUAL = 100; -export const PARAM_DUE_DATE = 'due_date'; -export const PARAM_SORT = 'sort'; export const PARAM_STATE = 'state'; export const RELATIVE_POSITION = 'relative_position'; @@ -68,21 +66,6 @@ export const largePageSizeParams = { firstPageSize: PAGE_SIZE_MANUAL, }; -export const DUE_DATE_NONE = '0'; -export const DUE_DATE_ANY = ''; -export const DUE_DATE_OVERDUE = 'overdue'; -export const DUE_DATE_WEEK = 'week'; -export const DUE_DATE_MONTH = 'month'; -export const DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS = 'next_month_and_previous_two_weeks'; -export const DUE_DATE_VALUES = [ - DUE_DATE_NONE, - DUE_DATE_ANY, - DUE_DATE_OVERDUE, - DUE_DATE_WEEK, - DUE_DATE_MONTH, - DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS, -]; - export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC'; export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC'; export const CREATED_ASC = 'CREATED_ASC'; diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index 01cc82ed8fd..3b2d37eab74 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -30,6 +30,7 @@ export function mountJiraIssuesListApp() { return new Vue({ el, + name: 'JiraIssuesImportStatusRoot', apolloProvider, render(createComponent) { return createComponent(JiraIssuesImportStatusRoot, { @@ -99,6 +100,7 @@ export function mountIssuesListApp() { hasMultipleIssueAssigneesFeature, importCsvIssuesPath, initialEmail, + initialSort, isAnonymousSearchDisabled, isIssueRepositioningDisabled, isProject, @@ -118,6 +120,7 @@ export function mountIssuesListApp() { return new Vue({ el, + name: 'IssuesListRoot', apolloProvider, provide: { autocompleteAwardEmojisPath, @@ -133,6 +136,7 @@ export function mountIssuesListApp() { hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasIterationsFeature: parseBoolean(hasIterationsFeature), hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature), + initialSort, isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled), isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled), isProject: parseBoolean(isProject), diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql index 07dae3fd756..430d494deab 100644 --- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql @@ -1,4 +1,5 @@ fragment IssueFragment on Issue { + __typename id iid closedAt @@ -18,6 +19,7 @@ fragment IssueFragment on Issue { webUrl assignees { nodes { + __typename id avatarUrl name @@ -26,6 +28,7 @@ fragment IssueFragment on Issue { } } author { + __typename id avatarUrl name diff --git a/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql index e7eb08104a6..040240cde99 100644 --- a/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql +++ b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql @@ -3,7 +3,13 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = false) { group(fullPath: $fullPath) @skip(if: $isProject) { id - milestones(searchTitle: $search, includeAncestors: true, includeDescendants: true) { + milestones( + searchTitle: $search + includeAncestors: true + includeDescendants: true + sort: EXPIRED_LAST_DUE_DATE_ASC + state: active + ) { nodes { ...Milestone } @@ -11,7 +17,12 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = fa } project(fullPath: $fullPath) @include(if: $isProject) { id - milestones(searchTitle: $search, includeAncestors: true) { + milestones( + searchTitle: $search + includeAncestors: true + sort: EXPIRED_LAST_DUE_DATE_ASC + state: active + ) { nodes { ...Milestone } diff --git a/app/assets/javascripts/issues/list/queries/set_sort_preference.mutation.graphql b/app/assets/javascripts/issues/list/queries/set_sort_preference.mutation.graphql new file mode 100644 index 00000000000..ed7b5193c9b --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/set_sort_preference.mutation.graphql @@ -0,0 +1,5 @@ +mutation setSortPreference($input: UserPreferencesUpdateInput!) { + userPreferencesUpdate(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index 2919bbbfef8..6322968b3f0 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -1,3 +1,9 @@ +import { isPositiveInteger } from '~/lib/utils/number_utils'; +import { __ } from '~/locale'; +import { + FILTERED_SEARCH_TERM, + OPERATOR_IS_NOT, +} from '~/vue_shared/components/filtered_search_bar/constants'; import { API_PARAM, BLOCKING_ISSUES_ASC, @@ -7,7 +13,6 @@ import { defaultPageSizeParams, DUE_DATE_ASC, DUE_DATE_DESC, - DUE_DATE_VALUES, filters, LABEL_PRIORITY_ASC, LABEL_PRIORITY_DESC, @@ -36,13 +41,7 @@ import { urlSortParams, WEIGHT_ASC, WEIGHT_DESC, -} from '~/issues/list/constants'; -import { isPositiveInteger } from '~/lib/utils/number_utils'; -import { __ } from '~/locale'; -import { - FILTERED_SEARCH_TERM, - OPERATOR_IS_NOT, -} from '~/vue_shared/components/filtered_search_bar/constants'; +} from './constants'; export const getInitialPageParams = (sortKey) => sortKey === RELATIVE_POSITION_ASC ? largePageSizeParams : defaultPageSizeParams; @@ -50,7 +49,7 @@ export const getInitialPageParams = (sortKey) => export const getSortKey = (sort) => Object.keys(urlSortParams).find((key) => urlSortParams[key] === sort); -export const getDueDateValue = (value) => (DUE_DATE_VALUES.includes(value) ? value : undefined); +export const isSortKey = (sort) => Object.keys(urlSortParams).includes(sort); export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) => { const sortOptions = [ diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js index c78505d0610..8fb891f62f7 100644 --- a/app/assets/javascripts/issues/manual_ordering.js +++ b/app/assets/javascripts/issues/manual_ordering.js @@ -7,12 +7,11 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; -const updateIssue = (url, issueList, { move_before_id, move_after_id }) => +const updateIssue = (url, { move_before_id, move_after_id }) => axios .put(`${url}/reorder`, { move_before_id, move_after_id, - group_full_path: issueList.dataset.groupFullPath, }) .catch(() => { createFlash({ @@ -52,7 +51,7 @@ const initManualOrdering = () => { const beforeId = prev && parseInt(prev.dataset.id, 10); const afterId = next && parseInt(next.dataset.id, 10); - updateIssue(url, issueList, { move_after_id: afterId, move_before_id: beforeId }); + updateIssue(url, { move_after_id: afterId, move_before_id: beforeId }); }, }), ); diff --git a/app/assets/javascripts/issues/new/index.js b/app/assets/javascripts/issues/new/index.js index f96cacf2595..91599502996 100644 --- a/app/assets/javascripts/issues/new/index.js +++ b/app/assets/javascripts/issues/new/index.js @@ -20,6 +20,7 @@ export function initTitleSuggestions() { return new Vue({ el, + name: 'TitleSuggestionsRoot', apolloProvider, data() { return { @@ -51,6 +52,7 @@ export function initTypePopover() { return new Vue({ el, + name: 'TypePopoverRoot', render: (createElement) => createElement(TypePopover), }); } diff --git a/app/assets/javascripts/issues/related_merge_requests/index.js b/app/assets/javascripts/issues/related_merge_requests/index.js index 5045f7e1a2a..196084093c8 100644 --- a/app/assets/javascripts/issues/related_merge_requests/index.js +++ b/app/assets/javascripts/issues/related_merge_requests/index.js @@ -13,6 +13,7 @@ export function initRelatedMergeRequests() { return new Vue({ el, + name: 'RelatedMergeRequestsRoot', store: createStore(), render: (createElement) => createElement(RelatedMergeRequests, { diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 7be4c13f544..eeccf886b65 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -1,18 +1,31 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { + GlSafeHtmlDirective as SafeHtml, + GlModal, + GlModalDirective, + GlPopover, + GlButton, +} from '@gitlab/ui'; import $ from 'jquery'; import createFlash from '~/flash'; import { __, sprintf } from '~/locale'; import TaskList from '~/task_list'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import animateMixin from '../mixins/animate'; export default { directives: { SafeHtml, + GlModal: GlModalDirective, }, - - mixins: [animateMixin], - + components: { + GlModal, + GlPopover, + CreateWorkItem, + GlButton, + }, + mixins: [animateMixin, glFeatureFlagMixin()], props: { canUpdate: { type: Boolean, @@ -53,8 +66,15 @@ export default { preAnimation: false, pulseAnimation: false, initialUpdate: true, + taskButtons: [], + activeTask: {}, }; }, + computed: { + workItemsEnabled() { + return this.glFeatures.workItems; + }, + }, watch: { descriptionHtml(newDescription, oldDescription) { if (!this.initialUpdate && newDescription !== oldDescription) { @@ -74,6 +94,10 @@ export default { mounted() { this.renderGFM(); this.updateTaskStatusText(); + + if (this.workItemsEnabled) { + this.renderTaskActions(); + } }, methods: { renderGFM() { @@ -132,6 +156,63 @@ export default { $tasksShort.text(''); } }, + renderTaskActions() { + if (!this.$el?.querySelectorAll) { + return; + } + + const taskListFields = this.$el.querySelectorAll('.task-list-item'); + + taskListFields.forEach((item, index) => { + const button = document.createElement('button'); + button.classList.add( + 'btn', + 'btn-default', + 'btn-md', + 'gl-button', + 'btn-default-tertiary', + 'gl-left-0', + 'gl-p-0!', + 'gl-top-2', + 'gl-absolute', + 'js-add-task', + ); + button.id = `js-task-button-${index}`; + this.taskButtons.push(button.id); + button.innerHTML = ` + <svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14"> + <use href="${gon.sprite_icons}#ellipsis_v"></use> + </svg> + `; + item.prepend(button); + }); + }, + openCreateTaskModal(id) { + this.activeTask = { id, title: this.$el.querySelector(`#${id}`).parentElement.innerText }; + this.$refs.modal.show(); + }, + closeCreateTaskModal() { + this.$refs.modal.hide(); + }, + handleCreateTask(title) { + const listItem = this.$el.querySelector(`#${this.activeTask.id}`).parentElement; + const taskBadge = document.createElement('span'); + taskBadge.innerHTML = ` + <svg data-testid="issue-open-m-icon" role="img" aria-hidden="true" class="gl-icon gl-fill-green-500 s12"> + <use href="${gon.sprite_icons}#issue-open-m"></use> + </svg> + <span class="badge badge-info badge-pill gl-badge sm gl-mr-1"> + ${__('Task')} + </span> + <a href="#">${title}</a> + `; + listItem.insertBefore(taskBadge, listItem.lastChild); + listItem.removeChild(listItem.lastChild); + this.closeCreateTaskModal(); + }, + focusButton() { + this.$refs.convertButton[0].$el.focus(); + }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] }, }; @@ -142,12 +223,14 @@ export default { v-if="descriptionHtml" :class="{ 'js-task-list-container': canUpdate, + 'work-items-enabled': workItemsEnabled, }" class="description" > <div ref="gfm-content" v-safe-html:[$options.safeHtmlConfig]="descriptionHtml" + data-testid="gfm-content" :class="{ 'issue-realtime-pre-pulse': preAnimation, 'issue-realtime-trigger-pulse': pulseAnimation, @@ -157,13 +240,46 @@ export default { <!-- eslint-disable vue/no-mutating-props --> <textarea v-if="descriptionText" - ref="textarea" v-model="descriptionText" :data-update-url="updateUrl" class="hidden js-task-list-field" dir="auto" + data-testid="textarea" > </textarea> <!-- eslint-enable vue/no-mutating-props --> + <gl-modal + ref="modal" + modal-id="create-task-modal" + :title="s__('WorkItem|New Task')" + hide-footer + body-class="gl-p-0!" + > + <create-work-item + :is-modal="true" + :initial-title="activeTask.title" + @closeModal="closeCreateTaskModal" + @onCreate="handleCreateTask" + /> + </gl-modal> + <template v-if="workItemsEnabled"> + <gl-popover + v-for="item in taskButtons" + :key="item" + :target="item" + placement="top" + triggers="focus" + @shown="focusButton" + > + <gl-button + ref="convertButton" + variant="link" + data-testid="convert-to-task" + class="gl-text-gray-900! gl-text-decoration-none! gl-outline-0!" + @click="openCreateTaskModal(item)" + >{{ s__('WorkItem|Convert to work item') }}</gl-button + > + </gl-popover> + </template> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index 5476a1ef897..d5ac7b28afc 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -1,13 +1,12 @@ <script> import markdownField from '~/vue_shared/components/markdown/field.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import updateMixin from '../../mixins/update'; export default { components: { markdownField, }, - mixins: [glFeatureFlagsMixin(), updateMixin], + mixins: [updateMixin], props: { formState: { type: Object, @@ -56,7 +55,7 @@ export default { v-model="formState.description" class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" dir="auto" - :data-supports-quick-actions="!glFeatures.tributeAutocomplete" + data-supports-quick-actions="true" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" @keydown.meta.enter="updateIssuable" diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index 7f5a0e32f72..f5c71f9691f 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -44,6 +44,7 @@ export function initIncidentApp(issueData = {}) { return new Vue({ el, + name: 'DescriptionRoot', apolloProvider, provide: { issueType: INCIDENT_TYPE, @@ -74,6 +75,8 @@ export function initIssueApp(issueData, store) { return undefined; } + const { fullPath } = el.dataset; + if (gon?.features?.fixCommentScroll) { scrollToTargetOnResize(); } @@ -84,10 +87,12 @@ export function initIssueApp(issueData, store) { return new Vue({ el, + name: 'DescriptionRoot', apolloProvider, store, provide: { canCreateIncident, + fullPath, }, computed: { ...mapGetters(['getNoteableData']), @@ -120,6 +125,7 @@ export function initHeaderActions(store, type = '') { return new Vue({ el, + name: 'HeaderActionsRoot', apolloProvider, store, provide: { @@ -154,6 +160,7 @@ export function initSentryErrorStackTrace() { return new Vue({ el, + name: 'SentryErrorStackTraceRoot', store: errorTrackingStore, render: (createElement) => createElement(SentryErrorStackTrace, { props: { issueStackTracePath } }), diff --git a/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue b/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue index 66fcb8e10eb..46c27c33f56 100644 --- a/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue +++ b/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue @@ -1,5 +1,6 @@ <script> -import { GlFormGroup, GlButton, GlFormInput, GlForm, GlAlert } from '@gitlab/ui'; +import { GlFormGroup, GlButton, GlFormInput, GlForm, GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { CREATE_BRANCH_ERROR_GENERIC, CREATE_BRANCH_ERROR_WITH_CONTEXT, @@ -7,6 +8,7 @@ import { I18N_NEW_BRANCH_LABEL_BRANCH, I18N_NEW_BRANCH_LABEL_SOURCE, I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT, + I18N_NEW_BRANCH_PERMISSION_ALERT, } from '../constants'; import createBranchMutation from '../graphql/mutations/create_branch.mutation.graphql'; import ProjectDropdown from './project_dropdown.vue'; @@ -17,6 +19,8 @@ const DEFAULT_ALERT_PARAMS = { title: '', message: '', variant: DEFAULT_ALERT_VARIANT, + link: undefined, + dismissible: true, }; export default { @@ -27,10 +31,16 @@ export default { GlFormInput, GlForm, GlAlert, + GlSprintf, + GlLink, ProjectDropdown, SourceBranchDropdown, }, - inject: ['initialBranchName'], + inject: { + initialBranchName: { + default: '', + }, + }, data() { return { selectedProject: null, @@ -40,6 +50,7 @@ export default { alertParams: { ...DEFAULT_ALERT_PARAMS, }, + hasPermission: false, }; }, computed: { @@ -49,19 +60,38 @@ export default { showAlert() { return Boolean(this.alertParams?.message); }, + isBranchNameValid() { + return (this.branchName ?? '').trim().length > 0; + }, disableSubmitButton() { - return !(this.selectedProject && this.selectedSourceBranchName && this.branchName); + return !(this.selectedProject && this.selectedSourceBranchName && this.isBranchNameValid); }, }, methods: { - displayAlert({ title, message, variant = DEFAULT_ALERT_VARIANT } = {}) { + displayAlert({ + title, + message, + variant = DEFAULT_ALERT_VARIANT, + link, + dismissible = true, + } = {}) { this.alertParams = { title, message, variant, + link, + dismissible, }; }, - onAlertDismiss() { + setPermissionAlert() { + this.displayAlert({ + message: I18N_NEW_BRANCH_PERMISSION_ALERT, + variant: 'warning', + link: helpPagePath('user/permissions', { anchor: 'project-members-permissions' }), + dismissible: false, + }); + }, + dismissAlert() { this.alertParams = { ...DEFAULT_ALERT_PARAMS, }; @@ -69,6 +99,14 @@ export default { onProjectSelect(project) { this.selectedProject = project; this.selectedSourceBranchName = null; // reset branch selection + this.hasPermission = this.selectedProject.userPermissions.pushCode; + + if (!this.hasPermission) { + this.setPermissionAlert(); + } else { + // clear alert if the user has permissions for the newly-selected project. + this.dismissAlert(); + } }, onSourceBranchSelect(branchName) { this.selectedSourceBranchName = branchName; @@ -127,10 +165,18 @@ export default { class="gl-mb-5" :variant="alertParams.variant" :title="alertParams.title" - @dismiss="onAlertDismiss" + :dismissible="alertParams.dismissible" + @dismiss="dismissAlert" > - {{ alertParams.message }} + <gl-sprintf :message="alertParams.message"> + <template #link="{ content }"> + <gl-link :href="alertParams.link" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> </gl-alert> + <gl-form-group :label="$options.i18n.I18N_NEW_BRANCH_LABEL_DROPDOWN" label-for="project-select"> <project-dropdown id="project-select" @@ -140,25 +186,28 @@ export default { /> </gl-form-group> - <gl-form-group - :label="$options.i18n.I18N_NEW_BRANCH_LABEL_BRANCH" - label-for="branch-name-input" - > - <gl-form-input id="branch-name-input" v-model="branchName" type="text" required /> - </gl-form-group> + <template v-if="selectedProject && hasPermission"> + <gl-form-group + :label="$options.i18n.I18N_NEW_BRANCH_LABEL_SOURCE" + label-for="source-branch-select" + > + <source-branch-dropdown + id="source-branch-select" + :selected-project="selectedProject" + :selected-branch-name="selectedSourceBranchName" + @change="onSourceBranchSelect" + @error="onError" + /> + </gl-form-group> - <gl-form-group - :label="$options.i18n.I18N_NEW_BRANCH_LABEL_SOURCE" - label-for="source-branch-select" - > - <source-branch-dropdown - id="source-branch-select" - :selected-project="selectedProject" - :selected-branch-name="selectedSourceBranchName" - @change="onSourceBranchSelect" - @error="onError" - /> - </gl-form-group> + <gl-form-group + :label="$options.i18n.I18N_NEW_BRANCH_LABEL_BRANCH" + label-for="branch-name-input" + class="gl-max-w-62" + > + <gl-form-input id="branch-name-input" v-model="branchName" type="text" required /> + </gl-form-group> + </template> <div class="form-actions"> <gl-button diff --git a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue index 751f3e9639d..88005cccd89 100644 --- a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue +++ b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue @@ -1,5 +1,11 @@ <script> -import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; +import { + GlDropdown, + GlSearchBoxByType, + GlLoadingIcon, + GlDropdownItem, + GlAvatarLabeled, +} from '@gitlab/ui'; import { __ } from '~/locale'; import { PROJECTS_PER_PAGE } from '../constants'; import getProjectsQuery from '../graphql/queries/get_projects.query.graphql'; @@ -14,6 +20,7 @@ export default { GlDropdownItem, GlSearchBoxByType, GlLoadingIcon, + GlAvatarLabeled, }, props: { selectedProject: { @@ -56,7 +63,7 @@ export default { return Boolean(this.$apollo.queries.projects.loading); }, projectDropdownText() { - return this.selectedProject?.nameWithNamespace || __('Select a project'); + return this.selectedProject?.nameWithNamespace || this.$options.i18n.selectProjectText; }, }, methods: { @@ -70,11 +77,19 @@ export default { return project.id === this.selectedProject?.id; }, }, + i18n: { + selectProjectText: __('Select a project'), + }, }; </script> <template> - <gl-dropdown :text="projectDropdownText" :loading="initialProjectsLoading"> + <gl-dropdown + :text="projectDropdownText" + :loading="initialProjectsLoading" + menu-class="gl-w-auto!" + :header-text="$options.i18n.selectProjectText" + > <template #header> <gl-search-box-by-type v-model.trim="projectSearchQuery" :debounce="250" /> </template> @@ -85,10 +100,20 @@ export default { v-for="project in projects" :key="project.id" is-check-item + is-check-centered :is-checked="isProjectSelected(project)" + :data-testid="`test-project-${project.id}`" @click="onProjectSelect(project)" > - {{ project.nameWithNamespace }} + <gl-avatar-labeled + class="gl-text-truncate" + shape="rect" + :size="32" + :src="project.avatarUrl" + :label="project.name" + :entity-name="project.name" + :sub-label="project.nameWithNamespace" + /> </gl-dropdown-item> </template> </gl-dropdown> diff --git a/app/assets/javascripts/jira_connect/branches/constants.js b/app/assets/javascripts/jira_connect/branches/constants.js index ab9d3b2c110..43be774ce7c 100644 --- a/app/assets/javascripts/jira_connect/branches/constants.js +++ b/app/assets/javascripts/jira_connect/branches/constants.js @@ -23,3 +23,6 @@ export const I18N_NEW_BRANCH_SUCCESS_TITLE = s__( export const I18N_NEW_BRANCH_SUCCESS_MESSAGE = s__( 'JiraConnect|You can now close this window and return to Jira.', ); +export const I18N_NEW_BRANCH_PERMISSION_ALERT = s__( + "JiraConnect|You don't have permission to create branches for this project. Select a different project or contact the project owner for access. %{linkStart}Learn more.%{linkEnd}", +); diff --git a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql index 32fbc1113bc..03e8e3e986b 100644 --- a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql +++ b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql @@ -26,6 +26,9 @@ query jiraGetProjects( repository { empty } + userPermissions { + pushCode + } } pageInfo { ...PageInfo diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue index 5a49d7c1a90..7f035dddafe 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue @@ -30,7 +30,8 @@ export default { page: 1, totalItems: 0, errorMessage: null, - searchTerm: '', + userSearchTerm: '', + searchValue: '', }; }, computed: { @@ -45,16 +46,11 @@ export default { }, methods: { loadGroups() { - // fetchGroups returns no results for search terms 0 < {length} < 3. - // The desired UX is to return the unfiltered results for searches {length} < 3. - // Here, we set the search to an empty string if {length} < 3 - const search = this.searchTerm?.length < MINIMUM_SEARCH_TERM_LENGTH ? '' : this.searchTerm; - this.isLoadingMore = true; return fetchGroups(this.groupsPath, { page: this.page, perPage: this.$options.DEFAULT_GROUPS_PER_PAGE, - search, + search: this.searchValue, }) .then((response) => { const { page, total } = parseIntPagination(normalizeHeaders(response.headers)); @@ -69,12 +65,24 @@ export default { this.isLoadingMore = false; }); }, - onGroupSearch(searchTerm) { - // keep a copy of the search term for pagination - this.searchTerm = searchTerm; - // reset the current page + onGroupSearch(userSearchTerm = '') { + this.userSearchTerm = userSearchTerm; + + // fetchGroups returns no results for search terms 0 < {length} < 3. + // The desired UX is to return the unfiltered results for searches {length} < 3. + // Here, we set the search to an empty string '' if {length} < 3 + const newSearchValue = + this.userSearchTerm.length < MINIMUM_SEARCH_TERM_LENGTH ? '' : this.userSearchTerm; + + // don't fetch new results if the search value didn't change. + if (newSearchValue === this.searchValue) { + return; + } + + // reset the page. this.page = 1; - return this.loadGroups(); + this.searchValue = newSearchValue; + this.loadGroups(); }, }, DEFAULT_GROUPS_PER_PAGE, @@ -92,7 +100,7 @@ export default { debounce="500" :placeholder="__('Search by name')" :is-loading="isLoadingMore" - :value="searchTerm" + :value="userSearchTerm" @input="onGroupSearch" /> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index 7fd4cc38f11..905e242e977 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -1,13 +1,13 @@ <script> -import { GlAlert, GlLink, GlSprintf, GlEmptyState } from '@gitlab/ui'; +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapState, mapMutations } from 'vuex'; import { retrieveAlert } from '~/jira_connect/subscriptions/utils'; import { SET_ALERT } from '../store/mutation_types'; -import SubscriptionsList from './subscriptions_list.vue'; -import AddNamespaceButton from './add_namespace_button.vue'; -import SignInButton from './sign_in_button.vue'; +import SignInPage from '../pages/sign_in.vue'; +import SubscriptionsPage from '../pages/subscriptions.vue'; import UserLink from './user_link.vue'; +import CompatibilityAlert from './compatibility_alert.vue'; export default { name: 'JiraConnectApp', @@ -15,11 +15,10 @@ export default { GlAlert, GlLink, GlSprintf, - GlEmptyState, - SubscriptionsList, - AddNamespaceButton, - SignInButton, UserLink, + CompatibilityAlert, + SignInPage, + SubscriptionsPage, }, inject: { usersPath: { @@ -58,11 +57,14 @@ export default { <template> <div> + <compatibility-alert /> + <gl-alert v-if="shouldShowAlert" class="gl-mb-7" :variant="alert.variant" :title="alert.title" + data-testid="jira-connect-persisted-alert" @dismiss="setAlert" > <gl-sprintf v-if="alert.linkUrl" :message="alert.message"> @@ -79,43 +81,9 @@ export default { <user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" /> <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> - <div class="jira-connect-app-body gl-mx-auto gl-px-5 gl-mb-7"> - <template v-if="hasSubscriptions"> - <div class="gl-display-flex gl-justify-content-end"> - <sign-in-button v-if="!userSignedIn" :users-path="usersPath" /> - <add-namespace-button v-else /> - </div> - - <subscriptions-list /> - </template> - <template v-else> - <div v-if="!userSignedIn" class="gl-text-center"> - <p class="gl-mb-7">{{ s__('JiraService|Sign in to GitLab.com to get started.') }}</p> - <sign-in-button class="gl-mb-7" :users-path="usersPath"> - {{ __('Sign in to GitLab') }} - </sign-in-button> - <p> - {{ - s__( - 'Integrations|Note: this integration only works with accounts on GitLab.com (SaaS).', - ) - }} - </p> - </div> - <gl-empty-state - v-else - :title="s__('Integrations|No linked namespaces')" - :description=" - s__( - 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.', - ) - " - > - <template #actions> - <add-namespace-button /> - </template> - </gl-empty-state> - </template> + <div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7"> + <sign-in-page v-if="!userSignedIn" :has-subscriptions="hasSubscriptions" /> + <subscriptions-page v-else :has-subscriptions="hasSubscriptions" /> </div> </div> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue new file mode 100644 index 00000000000..3cfbd87ac53 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue @@ -0,0 +1,63 @@ +<script> +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; + +const COMPATIBILITY_ALERT_STATE_KEY = 'compatibility_alert_dismissed'; + +export default { + name: 'CompatibilityAlert', + components: { + GlAlert, + GlSprintf, + GlLink, + LocalStorageSync, + }, + data() { + return { + alertDismissed: false, + }; + }, + computed: { + shouldShowAlert() { + return !this.alertDismissed; + }, + }, + methods: { + dismissAlert() { + this.alertDismissed = true; + }, + }, + i18n: { + title: s__('Integrations|Known limitations'), + body: s__( + 'Integrations|This integration only works with GitLab.com. Adding a namespace only works in browsers that allow cross-site cookies. %{linkStart}Learn more%{linkEnd}.', + ), + }, + DOCS_LINK_URL: helpPagePath('integration/jira/connect-app'), + COMPATIBILITY_ALERT_STATE_KEY, +}; +</script> +<template> + <local-storage-sync + v-model="alertDismissed" + :storage-key="$options.COMPATIBILITY_ALERT_STATE_KEY" + > + <gl-alert + v-if="shouldShowAlert" + class="gl-mb-7" + variant="info" + :title="$options.i18n.title" + @dismiss="dismissAlert" + > + <gl-sprintf :message="$options.i18n.body"> + <template #link="{ content }"> + <gl-link :href="$options.DOCS_LINK_URL" target="_blank" rel="noopener noreferrer">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + </local-storage-sync> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue index dc0a77e99c2..627abcdd4a0 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue @@ -1,6 +1,7 @@ <script> import { GlButton } from '@gitlab/ui'; import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils'; +import { s__ } from '~/locale'; export default { components: { @@ -25,12 +26,15 @@ export default { this.signInURL = await getGitlabSignInURL(this.usersPath); }, }, + i18n: { + defaultButtonText: s__('Integrations|Sign in to GitLab'), + }, }; </script> <template> <gl-button category="primary" variant="info" :href="signInURL" target="_blank"> <slot> - {{ s__('Integrations|Sign in to add namespaces') }} + {{ $options.i18n.defaultButtonText }} </slot> </gl-button> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue new file mode 100644 index 00000000000..2bce5afc72b --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue @@ -0,0 +1,40 @@ +<script> +import { s__ } from '~/locale'; +import SubscriptionsList from '../components/subscriptions_list.vue'; +import SignInButton from '../components/sign_in_button.vue'; + +export default { + name: 'SignInPage', + components: { + SubscriptionsList, + SignInButton, + }, + inject: ['usersPath'], + props: { + hasSubscriptions: { + type: Boolean, + required: true, + }, + }, + i18n: { + signinButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'), + signInText: s__('JiraService|Sign in to GitLab.com to get started.'), + }, +}; +</script> + +<template> + <div v-if="hasSubscriptions"> + <div class="gl-display-flex gl-justify-content-end"> + <sign-in-button :users-path="usersPath"> + {{ $options.i18n.signinButtonTextWithSubscriptions }} + </sign-in-button> + </div> + + <subscriptions-list /> + </div> + <div v-else class="gl-text-center"> + <p class="gl-mb-7">{{ $options.i18n.signInText }}</p> + <sign-in-button class="gl-mb-7" :users-path="usersPath" /> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue new file mode 100644 index 00000000000..426f2999370 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue @@ -0,0 +1,43 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import SubscriptionsList from '../components/subscriptions_list.vue'; +import AddNamespaceButton from '../components/add_namespace_button.vue'; + +export default { + name: 'SubscriptionsPage', + components: { + GlEmptyState, + SubscriptionsList, + AddNamespaceButton, + }, + props: { + hasSubscriptions: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <div v-if="hasSubscriptions"> + <div class="gl-display-flex gl-justify-content-end"> + <add-namespace-button /> + </div> + + <subscriptions-list /> + </div> + <gl-empty-state + v-else + :title="s__('Integrations|No linked namespaces')" + :description=" + s__( + 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.', + ) + " + > + <template #actions> + <add-namespace-button /> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue index 7dfa963a857..753a15871ab 100644 --- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue @@ -58,6 +58,14 @@ export default { required: true, }, }, + data() { + return { + retryBtnDisabled: false, + cancelBtnDisabled: false, + playManualBtnDisabled: false, + unscheduleBtnDisabled: false, + }; + }, computed: { hasArtifacts() { return this.job.artifacts.nodes.find((artifact) => artifact.fileType === FILE_TYPE_ARCHIVE); @@ -132,15 +140,23 @@ export default { }); }, cancelJob() { + this.cancelBtnDisabled = true; + this.postJobAction(this.$options.jobCancel, cancelJobMutation); }, retryJob() { + this.retryBtnDisabled = true; + this.postJobAction(this.$options.jobRetry, retryJobMutation); }, playJob() { + this.playManualBtnDisabled = true; + this.postJobAction(this.$options.jobPlay, playJobMutation); }, unscheduleJob() { + this.unscheduleBtnDisabled = true; + this.postJobAction(this.$options.jobUnschedule, unscheduleJobMutation); }, }, @@ -155,6 +171,7 @@ export default { data-testid="cancel-button" icon="cancel" :title="$options.CANCEL" + :disabled="cancelBtnDisabled" @click="cancelJob()" /> <template v-else-if="isScheduled"> @@ -179,6 +196,7 @@ export default { <gl-button icon="time-out" :title="$options.ACTIONS_UNSCHEDULE" + :disabled="unscheduleBtnDisabled" data-testid="unschedule" @click="unscheduleJob()" /> @@ -189,6 +207,7 @@ export default { v-if="manualJobPlayable" icon="play" :title="$options.ACTIONS_PLAY" + :disabled="playManualBtnDisabled" data-testid="play" @click="playJob()" /> @@ -197,6 +216,7 @@ export default { icon="repeat" :title="$options.ACTIONS_RETRY" :method="currentJobMethod" + :disabled="retryBtnDisabled" data-testid="retry" @click="retryJob()" /> diff --git a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue index ba5732d3d43..19594c4955d 100644 --- a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue @@ -39,6 +39,7 @@ export default { <time v-gl-tooltip :title="tooltipTitle(finishedTime)" + :datetime="finishedTime" data-placement="top" data-container="body" > diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue index c786d35ac68..81f42c1e293 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -51,7 +51,9 @@ export default { }, data() { return { - jobs: {}, + jobs: { + list: [], + }, hasError: false, isAlertDismissed: false, scope: null, diff --git a/app/assets/javascripts/labels/index.js b/app/assets/javascripts/labels/index.js index e87ad8d9a06..0d4113bba4c 100644 --- a/app/assets/javascripts/labels/index.js +++ b/app/assets/javascripts/labels/index.js @@ -11,6 +11,7 @@ import ProjectLabelSubscription from './project_label_subscription'; export function initDeleteLabelModal(optionalProps = {}) { new Vue({ + name: 'DeleteLabelModalRoot', render(h) { return h(DeleteLabelModal, { props: { @@ -65,6 +66,7 @@ export function initLabelIndex() { return new Vue({ el: '#js-promote-label-modal', + name: 'PromoteLabelModal', data() { return { modalProps: { diff --git a/app/assets/javascripts/lib/apollo/instrumentation_link.js b/app/assets/javascripts/lib/apollo/instrumentation_link.js index 2ab364557b8..bbe16d260e7 100644 --- a/app/assets/javascripts/lib/apollo/instrumentation_link.js +++ b/app/assets/javascripts/lib/apollo/instrumentation_link.js @@ -1,4 +1,4 @@ -import { ApolloLink } from 'apollo-link'; +import { ApolloLink } from '@apollo/client/core'; import { memoize } from 'lodash'; export const FEATURE_CATEGORY_HEADER = 'x-gitlab-feature-category'; diff --git a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js index 9b7901685b6..b2a86ac257b 100644 --- a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js +++ b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js @@ -1,5 +1,5 @@ -import { Observable } from 'apollo-link'; -import { onError } from 'apollo-link-error'; +import { Observable } from '@apollo/client/core'; +import { onError } from '@apollo/client/link/error'; import { isNavigatingAway } from '~/lib/utils/is_navigating_away'; /** diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index df2e85afe24..f533ba3671c 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,11 +1,9 @@ -import { InMemoryCache } from 'apollo-cache-inmemory'; -import { ApolloClient } from 'apollo-client'; -import { ApolloLink } from 'apollo-link'; -import { BatchHttpLink } from 'apollo-link-batch-http'; -import { HttpLink } from 'apollo-link-http'; +import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core'; +import { BatchHttpLink } from '@apollo/client/link/batch-http'; import { createUploadLink } from 'apollo-upload-client'; import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; +import possibleTypes from '~/graphql_shared/possibleTypes.json'; import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; import csrf from '~/lib/utils/csrf'; import { objectToQuery, queryToObject } from '~/lib/utils/url_utility'; @@ -21,6 +19,36 @@ export const fetchPolicies = { CACHE_ONLY: 'cache-only', }; +export const typePolicies = { + Repository: { + merge: true, + }, + UserPermissions: { + merge: true, + }, + MergeRequestPermissions: { + merge: true, + }, + ContainerRepositoryConnection: { + merge: true, + }, + TimelogConnection: { + merge: true, + }, + BranchList: { + merge: true, + }, + InstanceSecurityDashboard: { + merge: true, + }, + PipelinePermissions: { + merge: true, + }, + DesignCollection: { + merge: true, + }, +}; + export const stripWhitespaceFromQuery = (url, path) => { /* eslint-disable-next-line no-unused-vars */ const [_, params] = url.split(path); @@ -46,6 +74,30 @@ export const stripWhitespaceFromQuery = (url, path) => { return `${path}?${reassembled}`; }; +const acs = []; + +let pendingApolloMutations = 0; + +// ### Why track pendingApolloMutations, but calculate pendingApolloRequests? +// +// In Apollo 2, we had a single link for counting operations. +// +// With Apollo 3, the `forward().map(...)` of deduped queries is never called. +// So, we resorted to calculating the sum of `inFlightLinkObservables?.size`. +// However! Mutations don't use `inFLightLinkObservables`, but since they are likely +// not deduped we can count them... +// +// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55062#note_838943715 +// https://www.apollographql.com/docs/react/v2/networking/network-layer/#query-deduplication +Object.defineProperty(window, 'pendingApolloRequests', { + get() { + return acs.reduce( + (sum, ac) => sum + (ac?.queryManager?.inFlightLinkObservables?.size || 0), + pendingApolloMutations, + ); + }, +}); + export default (resolvers = {}, config = {}) => { const { baseUrl, @@ -56,6 +108,7 @@ export default (resolvers = {}, config = {}) => { path = '/api/graphql', useGet = false, } = config; + let ac = null; let uri = `${gon.relative_url_root || ''}${path}`; if (baseUrl) { @@ -75,16 +128,6 @@ export default (resolvers = {}, config = {}) => { batchMax, }; - const requestCounterLink = new ApolloLink((operation, forward) => { - window.pendingApolloRequests = window.pendingApolloRequests || 0; - window.pendingApolloRequests += 1; - - return forward(operation).map((response) => { - window.pendingApolloRequests -= 1; - return response; - }); - }); - /* This custom fetcher intervention is to deal with an issue where we are using GET to access eTag polling, but Apollo Client adds excessive whitespace, which causes the @@ -138,6 +181,22 @@ export default (resolvers = {}, config = {}) => { ); }; + const hasMutation = (operation) => + (operation?.query?.definitions || []).some((x) => x.operation === 'mutation'); + + const requestCounterLink = new ApolloLink((operation, forward) => { + if (hasMutation(operation)) { + pendingApolloMutations += 1; + } + + return forward(operation).map((response) => { + if (hasMutation(operation)) { + pendingApolloMutations -= 1; + } + return response; + }); + }); + const appLink = ApolloLink.split( hasSubscriptionOperation, new ActionCableLink(), @@ -155,19 +214,23 @@ export default (resolvers = {}, config = {}) => { ), ); - return new ApolloClient({ + ac = new ApolloClient({ typeDefs, link: appLink, cache: new InMemoryCache({ + typePolicies, + possibleTypes, ...cacheConfig, - freezeResults: true, }), resolvers, - assumeImmutableResults: true, defaultOptions: { query: { fetchPolicy, }, }, }); + + acs.push(ac); + + return ac; }; diff --git a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js new file mode 100644 index 00000000000..6473683c3af --- /dev/null +++ b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js @@ -0,0 +1,3 @@ +// Import from `src/to_markdown` to avoid unnecessary bundling of unused libs +// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79859 +export * from 'prosemirror-markdown/src/to_markdown'; diff --git a/app/assets/javascripts/lib/utils/apollo_startup_js_link.js b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js index 014823f3831..f240226e991 100644 --- a/app/assets/javascripts/lib/utils/apollo_startup_js_link.js +++ b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js @@ -1,4 +1,4 @@ -import { ApolloLink, Observable } from 'apollo-link'; +import { ApolloLink, Observable } from '@apollo/client/core'; import { parse } from 'graphql'; import { isEqual, pickBy } from 'lodash'; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index eff00dff7a7..cf6ce2c4889 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -705,7 +705,10 @@ export const scopedLabelKey = ({ title = '' }) => { }; // Methods to set and get Cookie -export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 }); +export const setCookie = (name, value, attributes) => { + const defaults = { expires: 365, secure: Boolean(window.gon?.secure) }; + Cookies.set(name, value, { ...defaults, ...attributes }); +}; export const getCookie = (name) => Cookies.get(name); diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue index 733d0f69f5d..f3380b7b4ba 100644 --- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue +++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue @@ -1,13 +1,21 @@ <script> -import { GlModal } from '@gitlab/ui'; +import { GlModal, GlSafeHtmlDirective } from '@gitlab/ui'; import { __ } from '~/locale'; export default { cancelAction: { text: __('Cancel') }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, components: { GlModal, }, props: { + title: { + type: String, + required: false, + default: '', + }, primaryText: { type: String, required: false, @@ -18,11 +26,27 @@ export default { required: false, default: 'confirm', }, + modalHtmlMessage: { + type: String, + required: false, + default: '', + }, + hideCancel: { + type: Boolean, + required: false, + default: false, + }, }, computed: { primaryAction() { return { text: this.primaryText, attributes: { variant: this.primaryVariant } }; }, + cancelAction() { + return this.hideCancel ? null : this.$options.cancelAction; + }, + shouldShowHeader() { + return Boolean(this.title?.length); + }, }, mounted() { this.$refs.modal.show(); @@ -36,12 +60,14 @@ export default { size="sm" modal-id="confirmationModal" body-class="gl-display-flex" + :title="title" :action-primary="primaryAction" - :action-cancel="$options.cancelAction" - hide-header + :action-cancel="cancelAction" + :hide-header="!shouldShowHeader" @primary="$emit('confirmed')" @hidden="$emit('closed')" > - <div class="gl-align-self-center"><slot></slot></div> + <div v-if="!modalHtmlMessage" class="gl-align-self-center"><slot></slot></div> + <div v-else v-safe-html="modalHtmlMessage" class="gl-align-self-center"></div> </gl-modal> </template> diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js index fdd0e045d07..a8a89d0644a 100644 --- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js +++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js @@ -1,6 +1,9 @@ import Vue from 'vue'; -export function confirmAction(message, { primaryBtnVariant, primaryBtnText } = {}) { +export function confirmAction( + message, + { primaryBtnVariant, primaryBtnText, modalHtmlMessage, title, hideCancel } = {}, +) { return new Promise((resolve) => { let confirmed = false; @@ -15,6 +18,9 @@ export function confirmAction(message, { primaryBtnVariant, primaryBtnText } = { props: { primaryVariant: primaryBtnVariant, primaryText: primaryBtnText, + title, + modalHtmlMessage, + hideCancel, }, on: { confirmed() { diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 36c6545164e..379c57f3945 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,6 +1,7 @@ export const BYTES_IN_KIB = 1024; export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250; export const HIDDEN_CLASS = 'hidden'; +export const THOUSAND = 1000; export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index f46263c0e4d..b0e31fe729b 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -1,5 +1,5 @@ import { sprintf, __ } from '~/locale'; -import { BYTES_IN_KIB } from './constants'; +import { BYTES_IN_KIB, THOUSAND } from './constants'; /** * Function that allows a number with an X amount of decimals @@ -86,6 +86,27 @@ export function numberToHumanSize(size, digits = 2) { } /** + * Converts a number to kilos or megas. + * + * For example: + * - 123 becomes 123 + * - 123456 becomes 123.4k + * - 123456789 becomes 123.4m + * + * @param number Number to format + * @param digits The number of digits to appear after the decimal point + * @return {string} Formatted number + */ +export function numberToMetricPrefix(number, digits = 1) { + if (number < THOUSAND) { + return number.toString(); + } + if (number < THOUSAND ** 2) { + return `${(number / THOUSAND).toFixed(digits)}k`; + } + return `${(number / THOUSAND ** 2).toFixed(digits)}m`; +} +/** * A simple method that returns the value of a + b * It seems unessesary, but when combined with a reducer it * adds up all the values in an array. diff --git a/app/assets/javascripts/lib/utils/table_utility.js b/app/assets/javascripts/lib/utils/table_utility.js index 33db7686e0f..6d66335b832 100644 --- a/app/assets/javascripts/lib/utils/table_utility.js +++ b/app/assets/javascripts/lib/utils/table_utility.js @@ -1,3 +1,4 @@ +import { convertToSnakeCase, convertToCamelCase } from '~/lib/utils/text_utility'; import { DEFAULT_TH_CLASSES } from './constants'; /** @@ -7,3 +8,37 @@ import { DEFAULT_TH_CLASSES } from './constants'; * @returns {String} The classes to be used in GlTable fields object. */ export const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`; + +/** + * Converts a GlTable sort-changed event object into string format. + * This string can be used as a sort argument on GraphQL queries. + * + * @param {Object} - The table state context object. + * @returns {String} A string with the sort key and direction, for example 'NAME_DESC'. + */ +export const sortObjectToString = ({ sortBy, sortDesc }) => { + const sortingDirection = sortDesc ? 'DESC' : 'ASC'; + const sortingColumn = convertToSnakeCase(sortBy).toUpperCase(); + + return `${sortingColumn}_${sortingDirection}`; +}; + +/** + * Converts a sort string into a sort state object that can be used to + * set the sort order on GlTable. + * + * @param {String} - The string with the sort key and direction, for example 'NAME_DESC'. + * @returns {Object} An object with the sortBy and sortDesc properties. + */ +export const sortStringToObject = (sortString) => { + let sortBy = null; + let sortDesc = null; + + if (sortString && sortString.includes('_')) { + const [key, direction] = sortString.split(/_(ASC|DESC)$/); + sortBy = convertToCamelCase(key.toLowerCase()); + sortDesc = direction === 'DESC'; + } + + return { sortBy, sortDesc }; +}; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 40dd29bea76..ec6789d81ec 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -5,6 +5,12 @@ import { insertText } from '~/lib/utils/common_utils'; const LINK_TAG_PATTERN = '[{text}](url)'; +// at the start of a line, find any amount of whitespace followed by +// a bullet point character (*+-) and an optional checkbox ([ ] [x]) +// OR a number with a . after it and an optional checkbox ([ ] [x]) +// followed by one or more whitespace characters +const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isOl>[*+-])|(?<isUl>\d+\.))( \[([x ])\])?\s)(?<content>.)?/; + function selectedText(text, textarea) { return text.substring(textarea.selectionStart, textarea.selectionEnd); } @@ -13,8 +19,15 @@ function addBlockTags(blockTag, selected) { return `${blockTag}\n${selected}\n${blockTag}`; } -function lineBefore(text, textarea) { - const split = text.substring(0, textarea.selectionStart).trim().split('\n'); +function lineBefore(text, textarea, trimNewlines = true) { + let split = text.substring(0, textarea.selectionStart); + + if (trimNewlines) { + split = split.trim(); + } + + split = split.split('\n'); + return split[split.length - 1]; } @@ -284,9 +297,9 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo } /* eslint-disable @gitlab/require-i18n-strings */ -export function keypressNoteText(e) { +function handleSurroundSelectedText(e, textArea) { if (!gon.markdown_surround_selection) return; - if (this.selectionStart === this.selectionEnd) return; + if (textArea.selectionStart === textArea.selectionEnd) return; const keys = { '*': '**{text}**', // wraps with bold character @@ -306,7 +319,7 @@ export function keypressNoteText(e) { updateText({ tag, - textArea: this, + textArea, blockTag: '', wrap: true, select: '', @@ -316,6 +329,48 @@ export function keypressNoteText(e) { } /* eslint-enable @gitlab/require-i18n-strings */ +function handleContinueList(e, textArea) { + if (!gon.features?.markdownContinueLists) return; + if (!(e.key === 'Enter')) return; + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + if (textArea.selectionStart !== textArea.selectionEnd) return; + + const currentLine = lineBefore(textArea.value, textArea, false); + const result = currentLine.match(LIST_LINE_HEAD_PATTERN); + + if (result) { + const { indent, content, leader } = result.groups; + const prevLineEmpty = !content; + + if (prevLineEmpty) { + // erase previous empty list item - select the text and allow the + // natural line feed erase the text + textArea.selectionStart = textArea.selectionStart - result[0].length; + return; + } + + const itemInsert = `${indent}${leader}`; + + e.preventDefault(); + + updateText({ + tag: itemInsert, + textArea, + blockTag: '', + wrap: false, + select: '', + tagContent: '', + }); + } +} + +export function keypressNoteText(e) { + const textArea = this; + + handleContinueList(e, textArea); + handleSurroundSelectedText(e, textArea); +} + export function updateTextForToolbarBtn($toolbarBtn) { return updateText({ textArea: $toolbarBtn.closest('.md-area').find('textarea'), diff --git a/app/assets/javascripts/lib/utils/yaml.js b/app/assets/javascripts/lib/utils/yaml.js new file mode 100644 index 00000000000..9270d388342 --- /dev/null +++ b/app/assets/javascripts/lib/utils/yaml.js @@ -0,0 +1,121 @@ +/** + * This file adds a merge function to be used with a yaml Document as defined by + * the yaml@2.x package: https://eemeli.org/yaml/#yaml + * + * Ultimately, this functionality should be merged upstream into the package, + * track the progress of that effort at https://github.com/eemeli/yaml/pull/347 + * */ + +import { visit, Scalar, isCollection, isDocument, isScalar, isNode, isMap, isSeq } from 'yaml'; + +function getPath(ancestry) { + return ancestry.reduce((p, { key }) => { + return key !== undefined ? [...p, key.value] : p; + }, []); +} + +function getFirstChildNode(collection) { + let firstChildKey; + let type; + switch (collection.constructor.name) { + case 'YAMLSeq': // eslint-disable-line @gitlab/require-i18n-strings + return collection.items.find((i) => isNode(i)); + case 'YAMLMap': // eslint-disable-line @gitlab/require-i18n-strings + firstChildKey = collection.items[0]?.key; + if (!firstChildKey) return undefined; + return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey); + default: + type = collection.constructor?.name || typeof collection; + throw Error(`Cannot identify a child Node for type ${type}`); + } +} + +function moveMetaPropsToFirstChildNode(collection) { + const firstChildNode = getFirstChildNode(collection); + const { comment, commentBefore, spaceBefore } = collection; + if (!(comment || commentBefore || spaceBefore)) return; + if (!firstChildNode) + throw new Error('Cannot move meta properties to a child of an empty Collection'); // eslint-disable-line @gitlab/require-i18n-strings + Object.assign(firstChildNode, { comment, commentBefore, spaceBefore }); + Object.assign(collection, { + comment: undefined, + commentBefore: undefined, + spaceBefore: undefined, + }); +} + +function assert(isTypeFn, node, path) { + if (![isSeq, isMap].includes(isTypeFn)) { + throw new Error('assert() can only be used with isSeq() and isMap()'); + } + const expectedTypeName = isTypeFn === isSeq ? 'YAMLSeq' : 'YAMLMap'; // eslint-disable-line @gitlab/require-i18n-strings + if (!isTypeFn(node)) { + const type = node?.constructor?.name || typeof node; + throw new Error( + `Type conflict at "${path.join( + '.', + )}": Destination node is of type ${type}, the node to be merged is of type ${expectedTypeName}.`, + ); + } +} + +function mergeCollection(target, node, path) { + // In case both the source and the target node have comments or spaces + // We'll move them to their first child so they do not conflict + moveMetaPropsToFirstChildNode(node); + if (target.hasIn(path)) { + const targetNode = target.getIn(path, true); + assert(isSeq(node) ? isSeq : isMap, targetNode, path); + moveMetaPropsToFirstChildNode(targetNode); + } +} + +function mergePair(target, node, path) { + if (!isScalar(node.value)) return undefined; + if (target.hasIn([...path, node.key.value])) { + target.setIn(path, node); + } else { + target.addIn(path, node); + } + return visit.SKIP; +} + +function getVisitorFn(target, options) { + return { + Map: (_, node, ancestors) => { + mergeCollection(target, node, getPath(ancestors)); + }, + Pair: (_, node, ancestors) => { + mergePair(target, node, getPath(ancestors)); + }, + Seq: (_, node, ancestors) => { + const path = getPath(ancestors); + mergeCollection(target, node, path); + if (options.onSequence === 'replace') { + target.setIn(path, node); + return visit.SKIP; + } + node.items.forEach((item) => target.addIn(path, item)); + return visit.SKIP; + }, + }; +} + +/** Merge another collection into this */ +export function merge(target, source, options = {}) { + const opt = { + onSequence: 'replace', + ...options, + }; + const sourceNode = target.createNode(isDocument(source) ? source.contents : source); + if (!isCollection(sourceNode)) { + const type = source?.constructor?.name || typeof source; + throw new Error(`Cannot merge type "${type}", expected a Collection`); + } + if (!isCollection(target.contents)) { + // If the target doc is empty add the source to it directly + Object.assign(target, { contents: sourceNode }); + return; + } + visit(sourceNode, getVisitorFn(target, opt)); +} diff --git a/app/assets/javascripts/listbox/index.js b/app/assets/javascripts/listbox/index.js new file mode 100644 index 00000000000..f63171e2785 --- /dev/null +++ b/app/assets/javascripts/listbox/index.js @@ -0,0 +1,67 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; + +export function parseAttributes(el) { + const { items: itemsString, selected, right: rightString } = el.dataset; + + const items = JSON.parse(itemsString); + const right = parseBoolean(rightString); + + const { className } = el; + + return { items, selected, right, className }; +} + +export function initListbox(el, { onChange } = {}) { + if (!el) return null; + + const { items, selected, right, className } = parseAttributes(el); + + return new Vue({ + el, + data() { + return { + selected, + }; + }, + computed: { + text() { + return items.find(({ value }) => value === this.selected)?.text; + }, + }, + render(h) { + return h( + GlDropdown, + { + props: { + text: this.text, + right, + }, + class: className, + }, + items.map((item) => + h( + GlDropdownItem, + { + props: { + isCheckItem: true, + isChecked: this.selected === item.value, + }, + on: { + click: () => { + this.selected = item.value; + + if (typeof onChange === 'function') { + onChange(item); + } + }, + }, + }, + item.text, + ), + ), + ); + }, + }); +} diff --git a/app/assets/javascripts/listbox/redirect_behavior.js b/app/assets/javascripts/listbox/redirect_behavior.js new file mode 100644 index 00000000000..7e0ea2c4dfd --- /dev/null +++ b/app/assets/javascripts/listbox/redirect_behavior.js @@ -0,0 +1,22 @@ +import { initListbox } from '~/listbox'; +import { redirectTo } from '~/lib/utils/url_utility'; + +/** + * Instantiates GlListbox components with redirect behavior for tags created + * with the `gl_redirect_listbox_tag` HAML helper. + * + * NOTE: Do not import this script explicitly. Using `gl_redirect_listbox_tag` + * automatically injects the `redirect_listbox` bundle, which calls this + * function. + */ +export function initRedirectListboxBehavior() { + const elements = Array.from(document.querySelectorAll('.js-redirect-listbox')); + + return elements.map((el) => + initListbox(el, { + onChange({ href }) { + redirectTo(href); + }, + }), + ); +} diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index c9e7b034950..b0d31ca315e 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -2,6 +2,7 @@ import { GlSprintf, GlAlert, + GlLink, GlDropdown, GlDropdownSectionHeader, GlDropdownItem, @@ -20,6 +21,7 @@ import LogSimpleFilters from './log_simple_filters.vue'; export default { components: { GlSprintf, + GlLink, GlAlert, GlDropdown, GlDropdownSectionHeader, @@ -58,6 +60,7 @@ export default { return { isElasticStackCalloutDismissed: false, scrollDownButtonDisabled: true, + isDeprecationNoticeDismissed: false, }; }, computed: { @@ -151,6 +154,41 @@ export default { {{ s__('Metrics|Invalid time range, please verify.') }} </gl-alert> <gl-alert + v-if="!isDeprecationNoticeDismissed" + :title="s__('Deprecations|Feature deprecation and removal')" + class="mb-3" + variant="danger" + @dismiss="isDeprecationNoticeDismissed = true" + > + <gl-sprintf + :message=" + s__( + 'Deprecations|The metrics, logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.', + ) + " + > + <template #epic="{ content }"> + <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/7188" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + + <gl-sprintf + :message=" + s__( + 'Deprecations|For information on a possible replacement %{epicStart} learn more about Opstrace %{epicEnd}.', + ) + " + > + <template #epic="{ content }"> + <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/6976" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + <gl-alert v-if="logs.fetchError" class="mb-3" variant="danger" diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 376134afef0..f78b4da181e 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -15,11 +15,11 @@ import { initRails } from '~/lib/utils/rails_ujs'; import * as popovers from '~/popovers'; import * as tooltips from '~/tooltips'; import { initPrefetchLinks } from '~/lib/utils/navigation_utility'; +import { logHelloDeferred } from 'jh_else_ce/lib/logger/hello_deferred'; import initAlertHandler from './alert_handler'; import { addDismissFlashClickListener } from './flash'; import initTodoToggle from './header'; import initLayoutNav from './layout_nav'; -import { logHelloDeferred } from './lib/logger/hello_deferred'; import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils'; import { localTimeAgo } from './lib/utils/datetime/timeago_utility'; import { getLocationHash, visitUrl } from './lib/utils/url_utility'; diff --git a/app/assets/javascripts/members/components/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue index 9687eacb036..ec59f0f681c 100644 --- a/app/assets/javascripts/members/components/avatars/user_avatar.vue +++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue @@ -8,10 +8,14 @@ import { import { generateBadges } from 'ee_else_ce/members/utils'; import { glEmojiTag } from '~/emoji'; import { __ } from '~/locale'; +import { isUserBusy } from '~/set_status_modal/utils'; import { AVATAR_SIZE } from '../../constants'; export default { name: 'UserAvatar', + i18n: { + busy: __('Busy'), + }, avatarSize: AVATAR_SIZE, orphanedUserLabel: __('Orphaned member'), safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, @@ -46,7 +50,10 @@ export default { }).filter((badge) => badge.show); }, statusEmoji() { - return this.user?.status?.emoji; + return this.user?.showStatus && this.user?.status?.emoji; + }, + isUserBusy() { + return isUserBusy(this.user?.availability || ''); }, }, methods: { @@ -73,6 +80,11 @@ export default { :entity-id="user.id" > <template #meta> + <div v-if="isUserBusy" class="gl-p-1"> + <span class="gl-text-gray-500 gl-font-sm gl-font-weight-normal" + >({{ $options.i18n.busy }})</span + > + </div> <div v-if="statusEmoji" class="gl-p-1"> <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag(statusEmoji)" diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue index e9329fb1d88..633dee75237 100644 --- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue +++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue @@ -151,6 +151,7 @@ export default { :search-input-placeholder="filteredSearchBar.placeholder" :initial-filter-value="initialFilterValue" data-testid="members-filtered-search-bar" + data-qa-selector="members_filtered_search_bar_content" @onFilter="handleFilter" /> </template> diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index e09d16cf680..b4ba9aa36e7 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -11,7 +11,9 @@ import { ACTIVE_TAB_QUERY_PARAM_NAME, TAB_QUERY_PARAM_VALUES, MEMBER_STATE_AWAITING, + MEMBER_STATE_ACTIVE, USER_STATE_BLOCKED_PENDING_APPROVAL, + BADGE_LABELS_AWAITING_USER_SIGNUP, BADGE_LABELS_PENDING_OWNER_APPROVAL, } from '../../constants'; import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue'; @@ -154,8 +156,12 @@ export default { * @see {@link ~/app/serializers/member_entity.rb} * @returns {boolean} */ - isNewUser(memberInviteMetadata) { - return memberInviteMetadata && !memberInviteMetadata.userState; + isNewUser(memberInviteMetadata, memberState) { + return ( + memberInviteMetadata && + !memberInviteMetadata.userState && + memberState !== MEMBER_STATE_ACTIVE + ); }, /** * Returns whether the user is awaiting root approval @@ -204,6 +210,10 @@ export default { * @returns {string} */ inviteBadge(memberInviteMetadata, memberState) { + if (this.isNewUser(memberInviteMetadata, memberState)) { + return BADGE_LABELS_AWAITING_USER_SIGNUP; + } + if (this.shouldAddPendingOwnerApprovalBadge(memberInviteMetadata, memberState)) { return BADGE_LABELS_PENDING_OWNER_APPROVAL; } diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index 62241eaed04..273f1acebc7 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -111,6 +111,7 @@ export const MEMBER_STATE_CREATED = 0; export const MEMBER_STATE_AWAITING = 1; export const MEMBER_STATE_ACTIVE = 2; +export const BADGE_LABELS_AWAITING_USER_SIGNUP = __('Awaiting user signup'); export const BADGE_LABELS_PENDING_OWNER_APPROVAL = __('Pending owner approval'); export const DAYS_TO_EXPIRE_SOON = 7; diff --git a/app/assets/javascripts/merge_conflicts/store/actions.js b/app/assets/javascripts/merge_conflicts/store/actions.js index df515c4ac1a..9c101da52f5 100644 --- a/app/assets/javascripts/merge_conflicts/store/actions.js +++ b/app/assets/javascripts/merge_conflicts/store/actions.js @@ -1,4 +1,4 @@ -import Cookies from 'js-cookie'; +import { setCookie } from '~/lib/utils/common_utils'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -51,7 +51,7 @@ export const setFailedRequest = ({ commit }, message) => { export const setViewType = ({ commit }, viewType) => { commit(types.SET_VIEW_TYPE, viewType); - Cookies.set('diff_view', viewType); + setCookie('diff_view', viewType); }; export const setSubmitState = ({ commit }, isSubmitting) => { diff --git a/app/assets/javascripts/merge_conflicts/store/state.js b/app/assets/javascripts/merge_conflicts/store/state.js index 8f700f58e54..7a2e28183a7 100644 --- a/app/assets/javascripts/merge_conflicts/store/state.js +++ b/app/assets/javascripts/merge_conflicts/store/state.js @@ -1,7 +1,7 @@ -import Cookies from 'js-cookie'; +import { getCookie } from '~/lib/utils/common_utils'; import { VIEW_TYPES } from '../constants'; -const diffViewType = Cookies.get('diff_view'); +const diffViewType = getCookie('diff_view'); export default () => ({ isLoading: true, diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index a40caea1223..ad0117844cd 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -1,20 +1,21 @@ /* eslint-disable no-new, class-methods-use-this */ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; -import Cookies from 'js-cookie'; import Vue from 'vue'; +import { + getCookie, + parseUrlPathname, + isMetaClick, + parseBoolean, + scrollToElement, +} from '~/lib/utils/common_utils'; import createEventHub from '~/helpers/event_hub_factory'; import BlobForkSuggestion from './blob/blob_fork_suggestion'; import Diff from './diff'; import createFlash from './flash'; import { initDiffStatsDropdown } from './init_diff_stats_dropdown'; import axios from './lib/utils/axios_utils'; -import { - parseUrlPathname, - isMetaClick, - parseBoolean, - scrollToElement, -} from './lib/utils/common_utils'; + import { localTimeAgo } from './lib/utils/datetime_utility'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import { __ } from './locale'; @@ -514,7 +515,7 @@ export default class MergeRequestTabs { // Expand the issuable sidebar unless the user explicitly collapsed it expandView() { - if (parseBoolean(Cookies.get('collapsed_gutter'))) { + if (parseBoolean(getCookie('collapsed_gutter'))) { return; } const $gutterBtn = $('.js-sidebar-toggle'); diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js index 2ca5f104b4f..f90fdb04923 100644 --- a/app/assets/javascripts/milestones/index.js +++ b/app/assets/javascripts/milestones/index.js @@ -46,6 +46,7 @@ export function initPromoteMilestoneModal() { return new Vue({ el: promoteMilestoneModal, + name: 'PromoteMilestoneModalRoot', render(createElement) { return createElement(PromoteMilestoneModal); }, @@ -80,6 +81,7 @@ export function initDeleteMilestoneModal() { return new Vue({ el: '#js-delete-milestone-modal', + name: 'DeleteMilestoneModalRoot', data() { return { modalProps: { diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index c9767330b73..6467d953500 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,5 +1,13 @@ <script> -import { GlButton, GlModalDirective, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { + GlButton, + GlModalDirective, + GlTooltipDirective, + GlIcon, + GlAlert, + GlSprintf, + GlLink, +} from '@gitlab/ui'; import Mousetrap from 'mousetrap'; import VueDraggable from 'vuedraggable'; import { mapActions, mapState, mapGetters } from 'vuex'; @@ -38,6 +46,9 @@ export default { GroupEmptyState, VariablesSection, LinksSection, + GlAlert, + GlSprintf, + GlLink, }, directives: { GlModal: GlModalDirective, @@ -143,6 +154,7 @@ export default { isRearrangingPanels: false, originalDocumentTitle: document.title, hoveredPanel: '', + isDeprecationNoticeDismissed: false, }; }, computed: { @@ -392,9 +404,44 @@ export default { }, }; </script> - <template> <div class="prometheus-graphs" data-qa-selector="prometheus_graphs"> + <div> + <gl-alert + v-if="!isDeprecationNoticeDismissed" + :title="__('Feature deprecation and removal')" + class="mb-3" + variant="danger" + @dismiss="isDeprecationNoticeDismissed = true" + > + <gl-sprintf + :message=" + s__( + 'Deprecations|The metrics, logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.', + ) + " + > + <template #epic="{ content }"> + <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/7188" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + <gl-sprintf + :message=" + s__( + 'Deprecations|For information on a possible replacement %{epicStart} learn more about Opstrace %{epicEnd}.', + ) + " + > + <template #epic="{ content }"> + <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/6976" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + </div> <dashboard-header v-if="showHeader" ref="prometheusGraphsHeader" diff --git a/app/assets/javascripts/nav/components/top_nav_menu_item.vue b/app/assets/javascripts/nav/components/top_nav_menu_item.vue index 07c6fa7773a..bf1fd691ca8 100644 --- a/app/assets/javascripts/nav/components/top_nav_menu_item.vue +++ b/app/assets/javascripts/nav/components/top_nav_menu_item.vue @@ -35,7 +35,7 @@ export default { <gl-button category="tertiary" :href="menuItem.href" - class="top-nav-menu-item gl-display-block" + class="top-nav-menu-item gl-display-block gl-pr-3!" :class="[menuItem.css_class, { [$options.ACTIVE_CLASS]: menuItem.active }]" :aria-label="menuItem.title" v-bind="dataAttrs" diff --git a/app/assets/javascripts/nav/mount.js b/app/assets/javascripts/nav/mount.js index 51b6a31b8cb..7b0cc977107 100644 --- a/app/assets/javascripts/nav/mount.js +++ b/app/assets/javascripts/nav/mount.js @@ -12,6 +12,7 @@ const mount = (el, Component) => { return new Vue({ el, + name: 'TopNavRoot', store, render(h) { return h(Component, { diff --git a/app/assets/javascripts/network/raphael.js b/app/assets/javascripts/network/raphael.js index 22e06a35d91..e13471c0e51 100644 --- a/app/assets/javascripts/network/raphael.js +++ b/app/assets/javascripts/network/raphael.js @@ -1,12 +1,14 @@ import Raphael from 'raphael/raphael'; +import { formatDate } from '~/lib/utils/datetime_utility'; Raphael.prototype.commitTooltip = function commitTooltip(x, y, commit) { const boxWidth = 300; const icon = this.image(gon.relative_url_root + commit.author.icon, x, y, 20, 20); const nameText = this.text(x + 25, y + 10, commit.author.name); - const idText = this.text(x, y + 35, commit.id); - const messageText = this.text(x, y + 50, commit.message.replace(/\r?\n/g, ' \n ')); - const textSet = this.set(icon, nameText, idText, messageText).attr({ + const dateText = this.text(x, y + 35, formatDate(commit.date)); + const idText = this.text(x, y + 55, commit.id); + const messageText = this.text(x, y + 70, commit.message.replace(/\r?\n/g, ' \n ')); + const textSet = this.set(icon, nameText, dateText, idText, messageText).attr({ 'text-anchor': 'start', font: '12px Monaco, monospace', }); @@ -14,6 +16,9 @@ Raphael.prototype.commitTooltip = function commitTooltip(x, y, commit) { font: '14px Arial', 'font-weight': 'bold', }); + dateText.attr({ + fill: '#666', + }); idText.attr({ fill: '#AAA', }); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 996c008b881..a9948fed3b6 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -369,7 +369,7 @@ export default { class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area" data-qa-selector="comment_field" data-testid="comment-field" - :data-supports-quick-actions="!glFeatures.tributeAutocomplete" + data-supports-quick-actions="true" :aria-label="$options.i18n.comment" :placeholder="$options.i18n.bodyPlaceholder" @keydown.up="editCurrentUserLastNote()" diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index b2d5910fd3f..b4f7ba5f960 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -107,7 +107,7 @@ export default { <td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block"> {{ __('Unable to load the diff') }} <button - class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button" + class="btn-link btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button" @click="fetchDiff" > {{ __('Try again') }} diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index d6b65ed0e8b..ee22c118e11 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -5,7 +5,6 @@ import { getDraft, updateDraft } from '~/lib/utils/autosave'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import markdownField from '~/vue_shared/components/markdown/field.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; @@ -20,7 +19,7 @@ export default { GlSprintf, GlLink, }, - mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable], + mixins: [issuableStateMixin, resolvable], props: { noteBody: { type: String, @@ -349,7 +348,7 @@ export default { ref="textarea" v-model="updatedNoteBody" :disabled="isSubmitting" - :data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete" + :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" data-qa-selector="reply_field" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 8e32c3b3073..ddf72587ba3 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -5,6 +5,7 @@ import DraftNote from '~/batch_comments/components/draft_note.vue'; import createFlash from '~/flash'; import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import { isLoggedIn } from '~/lib/utils/common_utils'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { s__, __ } from '~/locale'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; @@ -170,12 +171,13 @@ export default { this.expandDiscussion({ discussionId: this.discussion.id }); } }, - cancelReplyForm(shouldConfirm, isDirty) { + async cancelReplyForm(shouldConfirm, isDirty) { if (shouldConfirm && isDirty) { const msg = s__('Notes|Are you sure you want to cancel creating this comment?'); - // eslint-disable-next-line no-alert - if (!window.confirm(msg)) { + const confirmed = await confirmAction(msg); + + if (!confirmed) { return; } } diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 3250a4818c7..7bad10616cc 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -3,6 +3,7 @@ import { GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import $ from 'jquery'; import { escape, isEmpty } from 'lodash'; import { mapGetters, mapActions } from 'vuex'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants'; import createFlash from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -243,14 +244,18 @@ export default { this.setSelectedCommentPositionHover(); this.$emit('handleEdit'); }, - deleteHandler() { + async deleteHandler() { const typeOfComment = this.note.isDraft ? __('pending comment') : __('comment'); - if ( - // eslint-disable-next-line no-alert - window.confirm( - sprintf(__('Are you sure you want to delete this %{typeOfComment}?'), { typeOfComment }), - ) - ) { + + const msg = sprintf(__('Are you sure you want to delete this %{typeOfComment}?'), { + typeOfComment, + }); + const confirmed = await confirmAction(msg, { + primaryBtnVariant: 'danger', + primaryBtnText: __('Delete Comment'), + }); + + if (confirmed) { this.isDeleting = true; this.$emit('handleDeleteNote', this.note); @@ -345,10 +350,11 @@ export default { parent: this.$el, }); }, - formCancelHandler({ shouldConfirm, isDirty }) { + async formCancelHandler({ shouldConfirm, isDirty }) { if (shouldConfirm && isDirty) { - // eslint-disable-next-line no-alert - if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) return; + const msg = __('Are you sure you want to cancel editing this comment?'); + const confirmed = await confirmAction(msg); + if (!confirmed) return; } this.$refs.noteBody.resetAutoSave(); if (this.oldContent) { diff --git a/app/assets/javascripts/notes/discussion_filters.js b/app/assets/javascripts/notes/discussion_filters.js index 7c9e7703d59..104e9d4183a 100644 --- a/app/assets/javascripts/notes/discussion_filters.js +++ b/app/assets/javascripts/notes/discussion_filters.js @@ -19,7 +19,7 @@ export default (store) => { return new Vue({ el: discussionFilterEl, - name: 'DiscussionFilter', + name: 'DiscussionFilterRoot', components: { DiscussionFilter, }, diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 2ce60976adb..19fa484d659 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -14,6 +14,7 @@ export default () => { // eslint-disable-next-line no-new new Vue({ el, + name: 'NotesRoot', components: { notesApp, }, diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index ad529eb99b6..93236b05100 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -3,8 +3,6 @@ import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_ import { updateHistory } from '../../lib/utils/url_utility'; import eventHub from '../event_hub'; -const isDiffsVirtualScrollingEnabled = () => window.gon?.features?.diffsVirtualScrolling; - /** * @param {string} selector * @returns {boolean} @@ -15,7 +13,7 @@ function scrollTo(selector, { withoutContext = false } = {}) { if (el) { scrollFunction(el, { - behavior: isDiffsVirtualScrollingEnabled() ? 'auto' : 'smooth', + behavior: 'auto', }); return true; } @@ -31,7 +29,7 @@ function updateUrlWithNoteId(noteId) { replace: true, }; - if (noteId && isDiffsVirtualScrollingEnabled()) { + if (noteId) { // Temporarily mask the ID to avoid the browser default // scrolling taking over which is broken with virtual // scrolling enabled. @@ -115,17 +113,13 @@ function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId) const isDiffView = window.mrTabs.currentAction === 'diffs'; const targetId = fn(discussionId, isDiffView); const discussion = self.getDiscussion(targetId); - const setHash = !isDiffView && !isDiffsVirtualScrollingEnabled(); const discussionFilePath = discussion?.diff_file?.file_path; - if (isDiffsVirtualScrollingEnabled()) { - window.location.hash = ''; - } + window.location.hash = ''; if (discussionFilePath) { self.scrollToFile({ path: discussionFilePath, - setHash, }); } diff --git a/app/assets/javascripts/notes/sort_discussions.js b/app/assets/javascripts/notes/sort_discussions.js index ecfa3223039..ca8df880fe4 100644 --- a/app/assets/javascripts/notes/sort_discussions.js +++ b/app/assets/javascripts/notes/sort_discussions.js @@ -8,6 +8,7 @@ export default (store) => { return new Vue({ el, + name: 'SortDiscussionRoot', store, render(createElement) { return createElement(SortDiscussion); diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown.vue b/app/assets/javascripts/notifications/components/notifications_dropdown.vue index 69eb2115bf4..6b450c2b5fd 100644 --- a/app/assets/javascripts/notifications/components/notifications_dropdown.vue +++ b/app/assets/javascripts/notifications/components/notifications_dropdown.vue @@ -42,6 +42,9 @@ export default { showLabel: { default: false, }, + noFlip: { + default: false, + }, }, data() { return { @@ -127,6 +130,7 @@ export default { :disabled="disabled" :split="isCustomNotification" :text="buttonText" + :no-flip="noFlip" @click="openNotificationsModal" > <notifications-dropdown-item diff --git a/app/assets/javascripts/notifications/index.js b/app/assets/javascripts/notifications/index.js index d60a368703c..a81f2c2590b 100644 --- a/app/assets/javascripts/notifications/index.js +++ b/app/assets/javascripts/notifications/index.js @@ -21,6 +21,7 @@ export default () => { projectId, groupId, showLabel, + noFlip, } = el.dataset; return new Vue({ @@ -35,6 +36,7 @@ export default () => { projectId, groupId, showLabel: parseBoolean(showLabel), + noFlip: parseBoolean(noFlip), }, render(h) { return h(NotificationsDropdown); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue index 4fda4058711..7659ba5f9ea 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue @@ -7,6 +7,7 @@ import RegistryList from '~/packages_and_registries/shared/components/registry_l import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE, @@ -20,7 +21,6 @@ import { } from '../../constants/index'; import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql'; import TagsListRow from './tags_list_row.vue'; -import TagsLoader from './tags_loader.vue'; export default { name: 'TagsList', diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue index bb687ffdb89..931849c9918 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue @@ -5,6 +5,7 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; +import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; import DeleteImage from '../components/delete_image.vue'; import DeleteAlert from '../components/details_page/delete_alert.vue'; import DeleteModal from '../components/details_page/delete_modal.vue'; @@ -12,7 +13,6 @@ import DetailsHeader from '../components/details_page/details_header.vue'; import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue'; import StatusAlert from '../components/details_page/status_alert.vue'; import TagsList from '../components/details_page/tags_list.vue'; -import TagsLoader from '../components/details_page/tags_loader.vue'; import { ALERT_SUCCESS_TAG, diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue index 3274de05803..e2acebf39d6 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue @@ -52,7 +52,7 @@ export default { ), CliCommands: () => import( - /* webpackChunkName: 'container_registry_components' */ '../components/list_page/cli_commands.vue' + /* webpackChunkName: 'container_registry_components' */ '~/packages_and_registries/shared/components/cli_commands.vue' ), GlModal, GlSprintf, @@ -68,7 +68,7 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [Tracking.mixin()], - inject: ['config'], + inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'], loader: { repeat: 10, width: 1000, @@ -96,6 +96,9 @@ export default { return data[this.graphqlResource]?.containerRepositories.nodes; }, result({ data }) { + if (!data) { + return; + } this.pageInfo = data[this.graphqlResource]?.containerRepositories?.pageInfo; this.containerRepositoriesCount = data[this.graphqlResource]?.containerRepositoriesCount; }, @@ -321,7 +324,12 @@ export default { :hide-expiration-policy-data="config.isGroupPage" > <template #commands> - <cli-commands v-if="showCommands" /> + <cli-commands + v-if="showCommands" + :docker-build-command="dockerBuildCommand" + :docker-push-command="dockerPushCommand" + :docker-login-command="dockerLoginCommand" + /> </template> </registry-header> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue index 2a479c65d0c..9bab08b8548 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue @@ -21,13 +21,17 @@ export default { }, }, computed: { - showModuleCount() { - return Number.isInteger(this.count); + hasModules() { + return Number.isInteger(this.count) && this.count > 0; }, moduleAmountText() { return n__(`%d Module`, `%d Modules`, this.count); }, infoMessages() { + if (!this.hasModules) { + return []; + } + return [{ text: this.$options.i18n.LIST_INTRO_TEXT, link: this.helpUrl }]; }, }, @@ -43,11 +47,7 @@ export default { <template> <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages"> <template #metadata-amount> - <metadata-item - v-if="showModuleCount" - icon="infrastructure-registry" - :text="moduleAmountText" - /> + <metadata-item v-if="hasModules" icon="infrastructure-registry" :text="moduleAmountText" /> </template> </title-area> </template> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue index 462618a7f12..184a24047eb 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue @@ -99,7 +99,7 @@ export default { <template> <div> <infrastructure-title :help-url="packageHelpUrl" :count="packagesCount" /> - <infrastructure-search @update="requestPackagesList" /> + <infrastructure-search v-if="packagesCount > 0" @update="requestPackagesList" /> <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> <template #empty-state> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue index 1afd1b69db0..57ff3cd2a83 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue @@ -61,11 +61,13 @@ export default { </template> <template #right-secondary> - <gl-sprintf :message="__('Created %{timestamp}')"> - <template #timestamp> - <time-ago-tooltip :time="packageEntity.createdAt" /> - </template> - </gl-sprintf> + <span> + <gl-sprintf :message="__('Created %{timestamp}')"> + <template #timestamp> + <time-ago-tooltip :time="packageEntity.createdAt" /> + </template> + </gl-sprintf> + </span> </template> </list-item> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue index 3483d23e251..c27083261b5 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue @@ -9,6 +9,8 @@ import { FILTERED_SEARCH_TERM, FILTERED_SEARCH_TYPE, } from '~/packages_and_registries/shared/constants'; +import { LIST_KEY_CREATED_AT } from '~/packages_and_registries/package_registry/constants'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import PackageTypeToken from './tokens/package_type_token.vue'; export default { @@ -22,13 +24,13 @@ export default { operators: OPERATOR_IS_ONLY, }, ], - components: { RegistrySearch, UrlSync }, + components: { RegistrySearch, UrlSync, LocalStorageSync }, inject: ['isGroupPage'], data() { return { filters: [], sorting: { - orderBy: 'name', + orderBy: LIST_KEY_CREATED_AT, sort: 'desc', }, mountRegistrySearch: false, @@ -94,19 +96,26 @@ export default { </script> <template> - <url-sync> - <template #default="{ updateQuery }"> - <registry-search - v-if="mountRegistrySearch" - :filter="filters" - :sorting="sorting" - :tokens="$options.tokens" - :sortable-fields="sortableFields" - @sorting:changed="updateSortingAndEmitUpdate" - @filter:changed="updateFilters" - @filter:submit="emitUpdate" - @query:changed="updateQuery" - /> - </template> - </url-sync> + <local-storage-sync + storage-key="package_registry_list_sorting" + :value="sorting" + as-json + @input="updateSorting" + > + <url-sync> + <template #default="{ updateQuery }"> + <registry-search + v-if="mountRegistrySearch" + :filter="filters" + :sorting="sorting" + :tokens="$options.tokens" + :sortable-fields="sortableFields" + @sorting:changed="updateSortingAndEmitUpdate" + @filter:changed="updateFilters" + @filter:submit="emitUpdate" + @query:changed="updateQuery" + /> + </template> + </url-sync> + </local-storage-sync> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json deleted file mode 100644 index c61a653d10b..00000000000 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "__schema": { - "types": [ - { - "kind": "UNION", - "name": "PackageMetadata", - "possibleTypes": [ - { "name": "ComposerMetadata" }, - { "name": "ConanMetadata" }, - { "name": "MavenMetadata" }, - { "name": "NugetMetadata" }, - { "name": "PypiMetadata" } - ] - } - ] - } -} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js index 21d6fbc9e1f..56f95fa2c1f 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js @@ -1,22 +1,9 @@ -import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import introspectionQueryResultData from './fragmentTypes.json'; - -const fragmentMatcher = new IntrospectionFragmentMatcher({ - introspectionQueryResultData, -}); Vue.use(VueApollo); export const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - cacheConfig: { - fragmentMatcher, - }, - }, - ), + defaultClient: createDefaultClient(), }); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue b/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue index 07ee3c6083b..de7ab3e6d7b 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue @@ -10,7 +10,7 @@ import { COPY_BUILD_TITLE, PUSH_COMMAND_LABEL, COPY_PUSH_TITLE, -} from '../../constants/index'; +} from '../constants'; const trackingLabel = 'quickstart_dropdown'; @@ -20,7 +20,20 @@ export default { CodeInstruction, }, mixins: [Tracking.mixin({ label: trackingLabel })], - inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'], + props: { + dockerBuildCommand: { + type: String, + required: true, + }, + dockerPushCommand: { + type: String, + required: true, + }, + dockerLoginCommand: { + type: String, + required: true, + }, + }, trackingLabel, i18n: { QUICK_START, diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue b/app/assets/javascripts/packages_and_registries/shared/components/tags_loader.vue index b7afa5fba33..b7afa5fba33 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/tags_loader.vue diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/index.js b/app/assets/javascripts/packages_and_registries/shared/constants/index.js new file mode 100644 index 00000000000..7659781d96e --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/shared/constants/index.js @@ -0,0 +1,2 @@ +export * from './package_registry'; +export * from './quick_start'; diff --git a/app/assets/javascripts/packages_and_registries/shared/constants.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js index afc72a2c627..afc72a2c627 100644 --- a/app/assets/javascripts/packages_and_registries/shared/constants.js +++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/quick_start.js b/app/assets/javascripts/packages_and_registries/shared/constants/quick_start.js new file mode 100644 index 00000000000..6a39c07eba2 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/shared/constants/quick_start.js @@ -0,0 +1,9 @@ +import { s__ } from '~/locale'; + +export const QUICK_START = s__('ContainerRegistry|CLI Commands'); +export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login'); +export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command'); +export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image'); +export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command'); +export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image'); +export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command'); diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue index c2510a16d2f..3ef75b3ef0e 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue +++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue @@ -140,8 +140,8 @@ export default { return { id: 'signup-settings-modal', text: n__( - 'ApplicationSettings|By making this change, you will automatically approve %d user with the pending approval status.', - 'ApplicationSettings|By making this change, you will automatically approve %d users with the pending approval status.', + 'ApplicationSettings|By making this change, you will automatically approve %d user who is pending approval.', + 'ApplicationSettings|By making this change, you will automatically approve %d users who are pending approval.', pendingUserCount, ), actionPrimary: { @@ -157,7 +157,7 @@ export default { actionCancel: { text: __('Cancel'), }, - title: s__('ApplicationSettings|Approve users in the pending approval status?'), + title: s__('ApplicationSettings|Approve users who are pending approval?'), }; }, }, diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js new file mode 100644 index 00000000000..67eee2c3209 --- /dev/null +++ b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js @@ -0,0 +1,52 @@ +import createFlash from '~/flash'; +import axios from '../../../lib/utils/axios_utils'; +import { __ } from '../../../locale'; + +export default class PayloadDownloader { + constructor(trigger) { + this.trigger = trigger; + } + + init() { + this.spinner = this.trigger.querySelector('.js-spinner'); + this.text = this.trigger.querySelector('.js-text'); + + this.trigger.addEventListener('click', (event) => { + event.preventDefault(); + + return this.requestPayload(); + }); + } + + requestPayload() { + this.spinner.classList.add('d-inline-flex'); + + return axios + .get(this.trigger.dataset.endpoint, { + responseType: 'json', + }) + .then(({ data }) => { + PayloadDownloader.downloadFile(data); + }) + .catch(() => { + createFlash({ + message: __('Error fetching payload data.'), + }); + }) + .finally(() => { + this.spinner.classList.remove('d-inline-flex'); + }); + } + + static downloadFile(data) { + const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); + + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = `${data.recorded_at.slice(0, 10)} payload.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(link.href); + } +} diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js index 08f6633f424..c017cf0afa2 100644 --- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js +++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js @@ -5,7 +5,6 @@ import { __ } from '../../../locale'; export default class PayloadPreviewer { constructor(trigger) { this.trigger = trigger; - this.container = document.querySelector(trigger.dataset.payloadSelector); this.isVisible = false; this.isInserted = false; } @@ -23,21 +22,27 @@ export default class PayloadPreviewer { }); } + getContainer() { + return document.querySelector(this.trigger.dataset.payloadSelector); + } + requestPayload() { if (this.isInserted) return this.showPayload(); - this.spinner.classList.add('d-inline-flex'); + this.spinner.classList.add('gl-display-inline-flex'); + + const container = this.getContainer(); return axios - .get(this.container.dataset.endpoint, { + .get(container.dataset.endpoint, { responseType: 'text', }) .then(({ data }) => { - this.spinner.classList.remove('d-inline-flex'); + this.spinner.classList.remove('gl-display-inline-flex'); this.insertPayload(data); }) .catch(() => { - this.spinner.classList.remove('d-inline-flex'); + this.spinner.classList.remove('gl-display-inline-flex'); createFlash({ message: __('Error fetching payload data.'), }); @@ -46,19 +51,19 @@ export default class PayloadPreviewer { hidePayload() { this.isVisible = false; - this.container.classList.add('d-none'); + this.getContainer().classList.add('gl-display-none'); this.text.textContent = __('Preview payload'); } showPayload() { this.isVisible = true; - this.container.classList.remove('d-none'); + this.getContainer().classList.remove('gl-display-none'); this.text.textContent = __('Hide payload'); } insertPayload(data) { this.isInserted = true; - this.container.innerHTML = data; + this.getContainer().innerHTML = data; this.showPayload(); } } diff --git a/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js b/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js new file mode 100644 index 00000000000..8a12e753847 --- /dev/null +++ b/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js @@ -0,0 +1,3 @@ +import initServiceUsageData from '~/admin/application_settings/setup_service_usage_data'; + +initServiceUsageData(); diff --git a/app/assets/javascripts/pages/admin/runners/show/index.js b/app/assets/javascripts/pages/admin/runners/show/index.js new file mode 100644 index 00000000000..f76f3a2430d --- /dev/null +++ b/app/assets/javascripts/pages/admin/runners/show/index.js @@ -0,0 +1,3 @@ +import { initAdminRunnerShow } from '~/runner/admin_runner_show'; + +initAdminRunnerShow(); diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index f6155b2ab2f..96487e14e30 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -1,8 +1,7 @@ import { GROUP_BADGE } from '~/badges/constants'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import initFilePickers from '~/file_pickers'; -import TransferDropdown from '~/groups/transfer_dropdown'; -import setupTransferEdit from '~/groups/transfer_edit'; +import initTransferGroupForm from '~/groups/init_transfer_group_form'; import groupsSelect from '~/groups_select'; import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; @@ -15,11 +14,11 @@ document.addEventListener('DOMContentLoaded', () => { initFilePickers(); initConfirmDanger(); initSettingsPanels(); + initTransferGroupForm(); dirtySubmitFactory( document.querySelectorAll('.js-general-settings-form, .js-general-permissions-form'), ); mountBadgeSettings(GROUP_BADGE); - setupTransferEdit('.js-group-transfer-form', '#new_parent_group_id'); // Initialize Subgroups selector groupsSelect(); @@ -28,6 +27,4 @@ document.addEventListener('DOMContentLoaded', () => { initSearchSettings(); initCascadingSettingsLockPopovers(); - - return new TransferDropdown(); }); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 01a371920f8..14ce3f775b1 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -1,6 +1,7 @@ import { groupMemberRequestFormatter } from '~/groups/members/utils'; import groupsSelect from '~/groups_select'; import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; +import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal'; import initInviteMembersForm from '~/invite_members/init_invite_members_form'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; @@ -56,6 +57,7 @@ groupsSelect(); memberExpirationDate(); memberExpirationDate('.js-access-expiration-date-groups'); initInviteMembersModal(); +initInviteGroupsModal(); initInviteMembersTrigger(); initInviteGroupTrigger(); diff --git a/app/assets/javascripts/pages/projects/imports/new/index.js b/app/assets/javascripts/pages/projects/imports/new/index.js new file mode 100644 index 00000000000..4acfc5265ac --- /dev/null +++ b/app/assets/javascripts/pages/projects/imports/new/index.js @@ -0,0 +1,3 @@ +import initProjectNew from '~/projects/project_new'; + +initProjectNew.bindEvents(); diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue index 42c40cda601..adae97c6b6f 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue @@ -2,7 +2,8 @@ import { GlProgressBar, GlSprintf, GlAlert } from '@gitlab/ui'; import eventHub from '~/invite_members/event_hub'; import { s__ } from '~/locale'; -import { ACTION_LABELS, ACTION_SECTIONS } from '../constants'; +import { getCookie, removeCookie, parseBoolean } from '~/lib/utils/common_utils'; +import { ACTION_LABELS, ACTION_SECTIONS, INVITE_MODAL_OPEN_COOKIE } from '../constants'; import LearnGitlabSectionCard from './learn_gitlab_section_card.vue'; export default { @@ -26,7 +27,7 @@ export default { required: true, type: Object, }, - inviteMembersOpen: { + inviteMembers: { type: Boolean, required: false, default: false, @@ -53,7 +54,7 @@ export default { }, }, mounted() { - if (this.inviteMembersOpen) { + if (this.inviteMembers && this.getCookieForInviteMembers()) { this.openInviteMembersModal('celebrate'); } @@ -63,8 +64,15 @@ export default { eventHub.$off('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert); }, methods: { + getCookieForInviteMembers() { + const value = parseBoolean(getCookie(INVITE_MODAL_OPEN_COOKIE)); + + removeCookie(INVITE_MODAL_OPEN_COOKIE); + + return value; + }, openInviteMembersModal(mode) { - eventHub.$emit('openModal', { mode, inviteeType: 'members', source: 'learn-gitlab' }); + eventHub.$emit('openModal', { mode, source: 'learn-gitlab' }); }, handleShowSuccessfulInvitationsAlert() { this.showSuccessfulInvitationsAlert = true; diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue index 3a401f5cb31..d0ec02bbd0c 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue @@ -31,14 +31,13 @@ export default { this.action === 'userAdded' && isExperimentVariant('invite_for_help_continuous_onboarding') ); }, + openInNewTab() { + return ACTION_LABELS[this.action]?.openInNewTab === true; + }, }, methods: { openModal() { - eventHub.$emit('openModal', { - inviteeType: 'members', - source: 'learn_gitlab', - tasksToBeDoneEnabled: true, - }); + eventHub.$emit('openModal', { source: 'learn_gitlab' }); }, }, }; @@ -61,8 +60,9 @@ export default { </gl-link> <gl-link v-else - target="_blank" + :target="openInNewTab ? '_blank' : '_self'" :href="value.url" + data-testid="uncompleted-learn-gitlab-link" data-track-action="click_link" :data-track-label="$options.i18n.ACTION_LABELS[action].title" data-track-property="Growth::Conversion::Experiment::LearnGitLab" diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js index 9e204aa6746..880cf699e5e 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js @@ -62,6 +62,7 @@ export const ACTION_LABELS = { description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'), section: 'deploy', position: 1, + openInNewTab: true, }, issueCreated: { title: s__('LearnGitLab|Create an issue'), @@ -94,3 +95,5 @@ export const ACTION_SECTIONS = { ), }, }; + +export const INVITE_MODAL_OPEN_COOKIE = 'confetti_post_signup'; diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js index 1f91cc46946..c62cab1a425 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import LearnGitlab from '../components/learn_gitlab.vue'; function initLearnGitlab() { @@ -13,13 +13,13 @@ function initLearnGitlab() { const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions)); const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections)); const project = convertObjectPropsToCamelCase(JSON.parse(el.dataset.project)); - const { inviteMembersOpen } = el.dataset; + const { inviteMembers } = el.dataset; return new Vue({ el, render(createElement) { return createElement(LearnGitlab, { - props: { actions, sections, project, inviteMembersOpen }, + props: { actions, sections, project, inviteMembers: parseBoolean(inviteMembers) }, }); }, }); diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index 5d830872ed9..50733d8a145 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -1,4 +1,8 @@ -import { initNewProjectCreation, initNewProjectUrlSelect } from '~/projects/new'; +import { + initNewProjectCreation, + initNewProjectUrlSelect, + initDeploymentTargetSelect, +} from '~/projects/new'; import initProjectVisibilitySelector from '~/projects/project_visibility'; import initProjectNew from '~/projects/project_new'; @@ -6,3 +10,4 @@ initProjectVisibilitySelector(); initProjectNew.bindEvents(); initNewProjectCreation(); initNewProjectUrlSelect(); +initDeploymentTargetSelect(); 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 42b08bcaa7b..ee70ff858be 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,8 +1,8 @@ <script> import { GlButton } from '@gitlab/ui'; -import Cookies from 'js-cookie'; import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils'; + import Translate from '../../../../../vue_shared/translate'; Vue.use(Translate); @@ -17,13 +17,13 @@ export default { inject: ['docsUrl', 'illustrationUrl'], data() { return { - calloutDismissed: parseBoolean(Cookies.get(cookieKey)), + calloutDismissed: parseBoolean(getCookie(cookieKey)), }; }, methods: { dismissCallout() { this.calloutDismissed = true; - Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 }); + setCookie(cookieKey, this.calloutDismissed); }, }, }; diff --git a/app/assets/javascripts/pages/projects/planning_hierarchy/index.js b/app/assets/javascripts/pages/projects/planning_hierarchy/index.js new file mode 100644 index 00000000000..d5dfe2d5f37 --- /dev/null +++ b/app/assets/javascripts/pages/projects/planning_hierarchy/index.js @@ -0,0 +1,3 @@ +import { initWorkItemsHierarchy } from '~/work_items_hierarchy/work_items_hierarchy_bundle'; + +initWorkItemsHierarchy(); diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index a26aeeb6db4..0c17bf2f344 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -1,7 +1,7 @@ /* eslint-disable func-names, no-return-assign */ import $ from 'jquery'; -import Cookies from 'js-cookie'; +import { setCookie } from '~/lib/utils/common_utils'; import initClonePanel from '~/clone_panel'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import createFlash from '~/flash'; @@ -24,19 +24,19 @@ export default class Project { } $('.js-hide-no-ssh-message').on('click', function (e) { - Cookies.set('hide_no_ssh_message', 'false'); + setCookie('hide_no_ssh_message', 'false'); $(this).parents('.js-no-ssh-key-message').remove(); return e.preventDefault(); }); $('.js-hide-no-password-message').on('click', function (e) { - Cookies.set('hide_no_password_message', 'false'); + setCookie('hide_no_password_message', 'false'); $(this).parents('.js-no-password-message').remove(); return e.preventDefault(); }); $('.hide-auto-devops-implicitly-enabled-banner').on('click', function (e) { const projectId = $(this).data('project-id'); const cookieKey = `hide_auto_devops_implicitly_enabled_banner_${projectId}`; - Cookies.set(cookieKey, 'false'); + setCookie(cookieKey, 'false'); $(this).parents('.auto-devops-implicitly-enabled-banner').remove(); return e.preventDefault(); }); diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index 947bbdacf2c..26c42247cf7 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -3,6 +3,7 @@ import initImportAProjectModal from '~/invite_members/init_import_a_project_moda import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; import initInviteMembersForm from '~/invite_members/init_invite_members_form'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; +import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { s__ } from '~/locale'; import memberExpirationDate from '~/member_expiration_date'; @@ -17,6 +18,7 @@ memberExpirationDate(); memberExpirationDate('.js-access-expiration-date-groups'); initImportAProjectModal(); initInviteMembersModal(); +initInviteGroupsModal(); initInviteMembersTrigger(); initInviteGroupTrigger(); diff --git a/app/assets/javascripts/pages/projects/security/configuration/index.js b/app/assets/javascripts/pages/projects/security/configuration/index.js index 5f801501b2f..f13a48c1224 100644 --- a/app/assets/javascripts/pages/projects/security/configuration/index.js +++ b/app/assets/javascripts/pages/projects/security/configuration/index.js @@ -1,3 +1,3 @@ import { initSecurityConfiguration } from '~/security_configuration'; -initSecurityConfiguration(document.querySelector('#js-security-configuration-static')); +initSecurityConfiguration(document.querySelector('#js-security-configuration')); diff --git a/app/assets/javascripts/pages/projects/serverless/index.js b/app/assets/javascripts/pages/projects/serverless/index.js index 640301dd478..9ae81b327b1 100644 --- a/app/assets/javascripts/pages/projects/serverless/index.js +++ b/app/assets/javascripts/pages/projects/serverless/index.js @@ -1,5 +1,3 @@ import ServerlessBundle from '~/serverless/serverless_bundle'; -import initServerlessSurveyBanner from '~/serverless/survey_banner'; -initServerlessSurveyBanner(); new ServerlessBundle(); // eslint-disable-line no-new 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 d5e00f54e91..184bda4410f 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 @@ -280,7 +280,7 @@ export default { } return s__( - 'ProjectSettings|View and edit files in this project. Non-project members will only have read access.', + 'ProjectSettings|View and edit files in this project. Non-project members have only read access.', ); }, cveIdRequestIsDisabled() { diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js index 58ceb524360..5cbb7a06bc1 100644 --- a/app/assets/javascripts/pages/users/index.js +++ b/app/assets/javascripts/pages/users/index.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import Cookies from 'js-cookie'; +import { setCookie } from '~/lib/utils/common_utils'; import UserCallout from '~/user_callout'; import UserTabs from './user_tabs'; @@ -10,7 +10,7 @@ function initUserProfile(action) { // hide project limit message $('.hide-project-limit-message').on('click', (e) => { e.preventDefault(); - Cookies.set('hide_project_limit_message', 'false'); + setCookie('hide_project_limit_message', 'false'); $(this).parents('.project-limit-message').remove(); }); } 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 ed30198244f..710f49b833c 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -124,6 +124,9 @@ export default { const fileName = this.requests[0].truncatedUrl; return `${fileName}_perf_bar_${Date.now()}.json`; }, + memoryReportPath() { + return mergeUrlParams({ performance_bar: 'memory' }, window.location.href); + }, }, mounted() { this.currentRequest = this.requestId; @@ -182,6 +185,15 @@ export default { s__('PerformanceBar|Download') }}</a> </div> + <div + v-if="currentRequest.details && env === 'development'" + id="peek-memory-report" + class="view" + > + <a class="gl-text-blue-200" :href="memoryReportPath">{{ + s__('PerformanceBar|Memory report') + }}</a> + </div> <div v-if="currentRequest.details" id="peek-flamegraph" class="view"> <span class="gl-text-white-200">{{ s__('PerformanceBar|Flamegraph with mode:') }}</span> <a class="gl-text-blue-200" :href="flamegraphPath('wall')">{{ diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js index 66e999ca43b..eb5b50dd1ec 100644 --- a/app/assets/javascripts/performance_bar/index.js +++ b/app/assets/javascripts/performance_bar/index.js @@ -20,6 +20,7 @@ const initPerformanceBar = (el) => { return new Vue({ el, + name: 'PerformanceBarRoot', components: { PerformanceBarApp: () => import('./components/performance_bar_app.vue'), }, diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index bc83844b8b9..b003302ec8e 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -7,10 +7,11 @@ const DEFERRED_LINK_CLASS = 'deferred-link'; export default class PersistentUserCallout { constructor(container, options = container.dataset) { - const { dismissEndpoint, featureId, deferLinks } = options; + const { dismissEndpoint, featureId, groupId, deferLinks } = options; this.container = container; this.dismissEndpoint = dismissEndpoint; this.featureId = featureId; + this.groupId = groupId; this.deferLinks = parseBoolean(deferLinks); this.init(); @@ -52,6 +53,7 @@ export default class PersistentUserCallout { axios .post(this.dismissEndpoint, { feature_name: this.featureId, + group_id: this.groupId, }) .then(() => { this.container.remove(); diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index a7f8704b559..337c204c36a 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -10,6 +10,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-new-user-signups-cap-reached', '.js-eoa-bronze-plan-banner', '.js-security-newsletter-callout', + '.js-approaching-seats-count-threshold', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue index 54c9688d88f..8ff1aea020f 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue @@ -1,11 +1,11 @@ <script> -import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; import { COMMIT_ACTION_CREATE, COMMIT_ACTION_UPDATE, COMMIT_FAILURE, COMMIT_SUCCESS, + COMMIT_SUCCESS_WITH_REDIRECT, } from '../../constants'; import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql'; import updateCurrentBranchMutation from '../../graphql/mutations/client/update_current_branch.mutation.graphql'; @@ -15,9 +15,6 @@ import getCurrentBranch from '../../graphql/queries/client/current_branch.query. import CommitForm from './commit_form.vue'; -const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; -const MR_TARGET_BRANCH = 'merge_request[target_branch]'; - export default { alertTexts: { [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), @@ -29,7 +26,7 @@ export default { components: { CommitForm, }, - inject: ['projectFullPath', 'ciConfigPath', 'newMergeRequestPath'], + inject: ['projectFullPath', 'ciConfigPath'], props: { ciFileContent: { type: String, @@ -74,16 +71,6 @@ export default { }, }, methods: { - redirectToNewMergeRequest(sourceBranch) { - const url = mergeUrlParams( - { - [MR_SOURCE_BRANCH]: sourceBranch, - [MR_TARGET_BRANCH]: this.currentBranch, - }, - this.newMergeRequestPath, - ); - redirectTo(url); - }, async onCommitSubmit({ message, targetBranch, openMergeRequest }) { this.isSaving = true; @@ -112,12 +99,25 @@ export default { if (errors?.length) { this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors }); - } else if (openMergeRequest) { - this.redirectToNewMergeRequest(targetBranch); } else { - this.$emit('commit', { type: COMMIT_SUCCESS }); + const commitBranch = targetBranch; + const params = openMergeRequest + ? { + type: COMMIT_SUCCESS_WITH_REDIRECT, + params: { + sourceBranch: commitBranch, + targetBranch: this.currentBranch, + }, + } + : { type: COMMIT_SUCCESS }; + + this.$emit('commit', { + ...params, + }); + this.updateLastCommitBranch(targetBranch); this.updateCurrentBranch(targetBranch); + if (this.currentBranch === targetBranch) { this.$emit('updateCommitSha'); } diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue index bfbf24c6b13..5177cea900c 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue @@ -5,6 +5,11 @@ import SourceEditor from '~/vue_shared/components/source_editor.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { + editorOptions: { + // Quick suggestions is so that monaco can provide + // autocomplete for keywords + quickSuggestions: true, + }, components: { SourceEditor, }, @@ -29,6 +34,7 @@ export default { <div class="gl-border-solid gl-border-gray-100 gl-border-1 gl-border-t-none!"> <source-editor ref="editor" + :editor-options="$options.editorOptions" :file-name="ciConfigPath" v-bind="$attrs" @[$options.readyEvent]="registerCiSchema($event)" diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue index 4f79a81d539..ead2076ec3b 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue @@ -9,7 +9,6 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { produce } from 'immer'; -import { fetchPolicies } from '~/lib/graphql'; import { historyPushState } from '~/lib/utils/common_utils'; import { setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -63,8 +62,6 @@ export default { return { availableBranches: [], branchSelected: null, - filteredBranches: [], - isSearchingBranches: false, pageLimit: this.paginationLimit, pageCounter: 0, searchTerm: '', @@ -76,10 +73,9 @@ export default { query: getAvailableBranchesQuery, variables() { return { - limit: this.paginationLimit, offset: 0, projectFullPath: this.projectFullPath, - searchPattern: '*', + ...this.availableBranchesVariables, }; }, update(data) { @@ -116,14 +112,24 @@ export default { }, }, computed: { - branches() { - return this.searchTerm.length > 0 ? this.filteredBranches : this.availableBranches; + availableBranchesVariables() { + if (this.searchTerm.length > 0) { + return { + limit: this.totalBranches, + searchPattern: `*${this.searchTerm}*`, + }; + } + + return { + limit: this.paginationLimit, + searchPattern: '*', + }; }, enableBranchSwitcher() { - return this.branches.length > 0 || this.searchTerm.length > 0; + return this.availableBranches.length > 0 || this.searchTerm.length > 0; }, isBranchesLoading() { - return this.$apollo.queries.availableBranches.loading || this.isSearchingBranches; + return this.$apollo.queries.availableBranches.loading; }, }, watch: { @@ -134,38 +140,21 @@ export default { }, }, methods: { - availableBranchesQueryVars(varsOverride = {}) { - if (this.searchTerm.length > 0) { - return { - limit: this.totalBranches, - offset: 0, - projectFullPath: this.projectFullPath, - searchPattern: `*${this.searchTerm}*`, - ...varsOverride, - }; - } - - return { - limit: this.paginationLimit, - offset: this.pageCounter * this.paginationLimit, - projectFullPath: this.projectFullPath, - searchPattern: '*', - ...varsOverride, - }; - }, // if there is no searchPattern, paginate by {paginationLimit} branches fetchNextBranches() { if ( this.isBranchesLoading || this.searchTerm.length > 0 || - this.branches.length >= this.totalBranches + this.availableBranches.length >= this.totalBranches ) { return; } this.$apollo.queries.availableBranches .fetchMore({ - variables: this.availableBranchesQueryVars(), + variables: { + offset: this.pageCounter * this.paginationLimit, + }, updateQuery(previousResult, { fetchMoreResult }) { const previousBranches = previousResult.project.repository.branchNames; const newBranches = fetchMoreResult.project.repository.branchNames; @@ -204,23 +193,6 @@ export default { async setSearchTerm(newSearchTerm) { this.pageCounter = 0; this.searchTerm = newSearchTerm.trim(); - - if (this.searchTerm === '') { - this.pageLimit = this.paginationLimit; - return; - } - - this.isSearchingBranches = true; - const fetchResults = await this.$apollo - .query({ - query: getAvailableBranchesQuery, - fetchPolicy: fetchPolicies.NETWORK_ONLY, - variables: this.availableBranchesQueryVars(), - }) - .catch(this.showFetchError); - - this.isSearchingBranches = false; - this.filteredBranches = fetchResults?.data?.project?.repository?.branchNames || []; }, showFetchError() { this.$emit('showError', { @@ -255,14 +227,14 @@ export default { </gl-dropdown-section-header> <gl-infinite-scroll - :fetched-items="branches.length" + :fetched-items="availableBranches.length" :max-list-height="250" data-qa-selector="branch_menu_container" @bottomReached="fetchNextBranches" > <template #items> <gl-dropdown-item - v-for="branch in branches" + v-for="branch in availableBranches" :key="branch" :is-checked="currentBranch === branch" :is-check-item="true" diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue index 72b492a5877..4b9c98135ec 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -49,7 +49,7 @@ export default { pipelineEtag: { query: getPipelineEtag, update(data) { - return data.etags.pipeline; + return data.etags?.pipeline; }, }, pipeline: { diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue index 7206f19d060..c72cff4c6f8 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue +++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue @@ -5,6 +5,7 @@ import { __, s__ } from '~/locale'; import { COMMIT_FAILURE, COMMIT_SUCCESS, + COMMIT_SUCCESS_WITH_REDIRECT, DEFAULT_FAILURE, DEFAULT_SUCCESS, LOAD_FAILURE_UNKNOWN, @@ -21,14 +22,18 @@ export default { GlAlert, CodeSnippetAlert, }, - errorTexts: { + + errors: { [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), [DEFAULT_FAILURE]: __('Something went wrong on our end.'), [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'), [PIPELINE_FAILURE]: s__('Pipelines|There was a problem with loading the pipeline data.'), }, - successTexts: { + success: { [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), + [COMMIT_SUCCESS_WITH_REDIRECT]: s__( + 'Pipelines|Your changes have been successfully committed. Now redirecting to the new merge request page.', + ), [DEFAULT_SUCCESS]: __('Your action succeeded.'), }, props: { @@ -65,42 +70,20 @@ export default { }, computed: { failure() { - switch (this.failureType) { - case LOAD_FAILURE_UNKNOWN: - return { - text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN], - variant: 'danger', - }; - case COMMIT_FAILURE: - return { - text: this.$options.errorTexts[COMMIT_FAILURE], - variant: 'danger', - }; - case PIPELINE_FAILURE: - return { - text: this.$options.errorTexts[PIPELINE_FAILURE], - variant: 'danger', - }; - default: - return { - text: this.$options.errorTexts[DEFAULT_FAILURE], - variant: 'danger', - }; - } + const { errors } = this.$options; + + return { + text: errors[this.failureType] ?? errors[DEFAULT_FAILURE], + variant: 'danger', + }; }, success() { - switch (this.successType) { - case COMMIT_SUCCESS: - return { - text: this.$options.successTexts[COMMIT_SUCCESS], - variant: 'info', - }; - default: - return { - text: this.$options.successTexts[DEFAULT_SUCCESS], - variant: 'info', - }; - } + const { success } = this.$options; + + return { + text: success[this.successType] ?? success[DEFAULT_SUCCESS], + variant: 'info', + }; }, }, created() { diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index bc79b0742e7..a65463d02aa 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -20,6 +20,7 @@ export const EDITOR_APP_VALID_STATUSES = [ export const COMMIT_FAILURE = 'COMMIT_FAILURE'; export const COMMIT_SUCCESS = 'COMMIT_SUCCESS'; +export const COMMIT_SUCCESS_WITH_REDIRECT = 'COMMIT_SUCCESS_WITH_REDIRECT'; export const DEFAULT_FAILURE = 'DEFAULT_FAILURE'; export const DEFAULT_SUCCESS = 'DEFAULT_SUCCESS'; diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 90f48195c5e..1da50c55a68 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon, GlModal } from '@gitlab/ui'; import { fetchPolicies } from '~/lib/graphql'; -import { queryToObject } from '~/lib/utils/url_utility'; +import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; @@ -11,6 +11,7 @@ import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_stat import PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue'; import { COMMIT_SHA_POLL_INTERVAL, + COMMIT_SUCCESS_WITH_REDIRECT, EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_LOADING, EDITOR_APP_STATUS_LINT_UNAVAILABLE, @@ -27,6 +28,9 @@ import getTemplate from './graphql/queries/get_starter_template.query.graphql'; import getLatestCommitShaQuery from './graphql/queries/latest_commit_sha.query.graphql'; import PipelineEditorHome from './pipeline_editor_home.vue'; +const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; +const MR_TARGET_BRANCH = 'merge_request[target_branch]'; + export default { components: { ConfirmUnsavedChangesDialog, @@ -36,14 +40,7 @@ export default { PipelineEditorHome, PipelineEditorMessages, }, - inject: { - ciConfigPath: { - default: '', - }, - projectFullPath: { - default: '', - }, - }, + inject: ['ciConfigPath', 'newMergeRequestPath', 'projectFullPath'], data() { return { ciConfigData: {}, @@ -57,7 +54,7 @@ export default { lastCommittedContent: '', shouldSkipStartScreen: false, showFailure: false, - showResetComfirmationModal: false, + showResetConfirmationModal: false, showStartScreen: false, showSuccess: false, starterTemplate: '', @@ -199,7 +196,7 @@ export default { currentBranch: { query: getCurrentBranch, update(data) { - return data.workBranches.current.name; + return data.workBranches?.current?.name; }, }, starterTemplate: { @@ -217,7 +214,7 @@ export default { return data.project?.ciTemplate?.content || ''; }, result({ data }) { - this.updateCiConfig(data.project?.ciTemplate?.content || ''); + this.updateCiConfig(data?.project?.ciTemplate?.content || ''); }, error() { this.reportFailure(LOAD_FAILURE_UNKNOWN); @@ -271,17 +268,39 @@ export default { this.checkShouldSkipStartScreen(); }, methods: { + checkShouldSkipStartScreen() { + const params = queryToObject(window.location.search); + this.shouldSkipStartScreen = Boolean(params?.add_new_config_file); + }, + confirmReset() { + if (this.hasUnsavedChanges) { + this.showResetConfirmationModal = true; + } + }, hideFailure() { this.showFailure = false; }, hideSuccess() { this.showSuccess = false; }, - confirmReset() { - if (this.hasUnsavedChanges) { - this.showResetComfirmationModal = true; + loadTemplateFromURL() { + const templateName = queryToObject(window.location.search)?.template; + + if (templateName) { + this.starterTemplateName = templateName; + this.setNewEmptyCiConfigFile(); } }, + redirectToNewMergeRequest(sourceBranch, targetBranch) { + const url = mergeUrlParams( + { + [MR_SOURCE_BRANCH]: sourceBranch, + [MR_TARGET_BRANCH]: targetBranch, + }, + this.newMergeRequestPath, + ); + redirectTo(url); + }, async refetchContent() { this.$apollo.queries.initialCiFileContent.skip = false; await this.$apollo.queries.initialCiFileContent.refetch(); @@ -298,7 +317,7 @@ export default { this.successType = type; }, resetContent() { - this.showResetComfirmationModal = false; + this.showResetConfirmationModal = false; this.currentCiFileContent = this.lastCommittedContent; }, setAppStatus(appStatus) { @@ -323,7 +342,7 @@ export default { this.isFetchingCommitSha = true; this.$apollo.queries.commitSha.refetch(); }, - updateOnCommit({ type }) { + async updateOnCommit({ type, params = {} }) { this.reportSuccess(type); if (this.isNewCiConfigFile) { @@ -333,19 +352,17 @@ export default { // Keep track of the latest committed content to know // if the user has made changes to the file that are unsaved. this.lastCommittedContent = this.currentCiFileContent; - }, - loadTemplateFromURL() { - const templateName = queryToObject(window.location.search)?.template; - if (templateName) { - this.starterTemplateName = templateName; - this.setNewEmptyCiConfigFile(); + if (type === COMMIT_SUCCESS_WITH_REDIRECT) { + const { sourceBranch, targetBranch } = params; + // This force update does 2 things for us: + // 1. It make sure `hasUnsavedChanges` is updated so + // we don't show a modal when the user creates an MR + // 2. Ensure the commit success banner is visible. + await this.$forceUpdate(); + this.redirectToNewMergeRequest(sourceBranch, targetBranch); } }, - checkShouldSkipStartScreen() { - const params = queryToObject(window.location.search); - this.shouldSkipStartScreen = Boolean(params?.add_new_config_file); - }, }, }; </script> @@ -358,7 +375,7 @@ export default { @createEmptyConfigFile="setNewEmptyCiConfigFile" @refetchContent="refetchContent" /> - <div v-else> + <div v-else class="gl-pr-10"> <pipeline-editor-messages :failure-type="failureType" :failure-reasons="failureReasons" @@ -382,7 +399,7 @@ export default { @updateCommitSha="updateCommitSha" /> <gl-modal - v-model="showResetComfirmationModal" + v-model="showResetConfirmationModal" modal-id="reset-content" :title="$options.i18n.resetModal.title" :action-cancel="$options.i18n.resetModal.actionCancel" diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index 96680080f0c..bb759477e1e 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -90,7 +90,7 @@ export default { </script> <template> - <div class="gl-pr-10 gl-transition-medium gl-w-full"> + <div class="gl-transition-medium gl-w-full"> <gl-modal v-if="showSwitchBranchModal" visible diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js index a6c9f3cb746..43f7634083b 100644 --- a/app/assets/javascripts/pipeline_new/constants.js +++ b/app/assets/javascripts/pipeline_new/constants.js @@ -1,3 +1,4 @@ +import { __ } from '~/locale'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; export const VARIABLE_TYPE = 'env_var'; @@ -7,5 +8,7 @@ export const CONFIG_VARIABLES_TIMEOUT = 5000; export const BRANCH_REF_TYPE = 'branch'; export const TAG_REF_TYPE = 'tag'; -export const CC_VALIDATION_REQUIRED_ERROR = - 'Credit card required to be on file in order to create a pipeline'; +// must match pipeline/chain/validate/after_config.rb +export const CC_VALIDATION_REQUIRED_ERROR = __( + 'Credit card required to be on file in order to create a pipeline', +); diff --git a/app/assets/javascripts/pipeline_wizard/components/commit.vue b/app/assets/javascripts/pipeline_wizard/components/commit.vue new file mode 100644 index 00000000000..518b41c66b1 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/commit.vue @@ -0,0 +1,224 @@ +<script> +import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormTextarea } from '@gitlab/ui'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import { __, s__, sprintf } from '~/locale'; +import createCommitMutation from '../queries/create_commit.graphql'; +import getFileMetaDataQuery from '../queries/get_file_meta.graphql'; +import StepNav from './step_nav.vue'; + +export const i18n = { + updateFileHeading: s__('PipelineWizard|Commit changes to your file'), + createFileHeading: s__('PipelineWizard|Commit your new file'), + fieldRequiredFeedback: __('This field is required'), + commitMessageLabel: s__('PipelineWizard|Commit Message'), + branchSelectorLabel: s__('PipelineWizard|Commit file to Branch'), + defaultUpdateCommitMessage: s__('PipelineWizardDefaultCommitMessage|Update %{filename}'), + defaultCreateCommitMessage: s__('PipelineWizardDefaultCommitMessage|Add %{filename}'), + commitButtonLabel: s__('PipelineWizard|Commit'), + commitSuccessMessage: s__('PipelineWizard|The file has been committed.'), + errors: { + loadError: s__( + 'PipelineWizard|There was a problem while checking whether your file already exists in the specified branch.', + ), + commitError: s__('PipelineWizard|There was a problem committing the changes.'), + }, +}; + +const COMMIT_ACTION = { + CREATE: 'CREATE', + UPDATE: 'UPDATE', +}; + +export default { + i18n, + name: 'PipelineWizardCommitStep', + components: { + RefSelector, + GlAlert, + GlButton, + GlForm, + GlFormGroup, + GlFormTextarea, + StepNav, + }, + props: { + prev: { + type: Object, + required: false, + default: null, + }, + projectPath: { + type: String, + required: true, + }, + defaultBranch: { + type: String, + required: true, + }, + fileContent: { + type: String, + required: false, + default: '', + }, + filename: { + type: String, + required: true, + }, + }, + data() { + return { + branch: this.defaultBranch, + loading: false, + loadError: null, + commitError: null, + message: null, + }; + }, + computed: { + fileExistsInRepo() { + return this.project?.repository?.blobs.nodes.length > 0; + }, + commitAction() { + return this.fileExistsInRepo ? COMMIT_ACTION.UPDATE : COMMIT_ACTION.CREATE; + }, + defaultMessage() { + return sprintf( + this.fileExistsInRepo + ? this.$options.i18n.defaultUpdateCommitMessage + : this.$options.i18n.defaultCreateCommitMessage, + { filename: this.filename }, + ); + }, + isCommitButtonEnabled() { + return this.fileExistsCheckInProgress; + }, + fileExistsCheckInProgress() { + return this.$apollo.queries.project.loading; + }, + mutationPayload() { + return { + mutation: createCommitMutation, + variables: { + input: { + projectPath: this.projectPath, + branch: this.branch, + message: this.message || this.defaultMessage, + actions: [ + { + action: this.commitAction, + filePath: `/${this.filename}`, + content: this.fileContent, + }, + ], + }, + }, + }; + }, + }, + apollo: { + project: { + query: getFileMetaDataQuery, + variables() { + this.loadError = null; + return { + fullPath: this.projectPath, + filePath: this.filename, + ref: this.branch, + }; + }, + error() { + this.loadError = this.$options.i18n.errors.loadError; + }, + }, + }, + methods: { + async commit() { + this.loading = true; + try { + const { data } = await this.$apollo.mutate(this.mutationPayload); + const hasError = Boolean(data.commitCreate.errors?.length); + if (hasError) { + this.commitError = this.$options.i18n.errors.commitError; + } else { + this.handleCommitSuccess(); + } + } catch (e) { + this.commitError = this.$options.i18n.errors.commitError; + } finally { + this.loading = false; + } + }, + handleCommitSuccess() { + this.$toast.show(this.$options.i18n.commitSuccessMessage); + this.$emit('done'); + }, + }, +}; +</script> + +<template> + <div> + <h4 v-if="fileExistsInRepo" key="create-heading"> + {{ $options.i18n.updateFileHeading }} + </h4> + <h4 v-else key="update-heading"> + {{ $options.i18n.createFileHeading }} + </h4> + <gl-alert + v-if="!!loadError" + :dismissible="false" + class="gl-mb-5" + data-testid="load-error" + variant="danger" + > + {{ loadError }} + </gl-alert> + <gl-form class="gl-max-w-48"> + <gl-form-group + :invalid-feedback="$options.i18n.fieldRequiredFeedback" + :label="$options.i18n.commitMessageLabel" + data-testid="commit_message_group" + label-for="commit_message" + > + <gl-form-textarea + id="commit_message" + v-model="message" + :placeholder="defaultMessage" + data-testid="commit_message" + size="md" + @input="(v) => $emit('update:message', v)" + /> + </gl-form-group> + <gl-form-group + :invalid-feedback="$options.i18n.fieldRequiredFeedback" + :label="$options.i18n.branchSelectorLabel" + data-testid="branch_selector_group" + label-for="branch" + > + <ref-selector id="branch" v-model="branch" data-testid="branch" :project-id="projectPath" /> + </gl-form-group> + <gl-alert + v-if="!!commitError" + :dismissible="false" + class="gl-mb-5" + data-testid="commit-error" + variant="danger" + > + {{ commitError }} + </gl-alert> + <step-nav show-back-button v-bind="$props" @back="$emit('go-back')"> + <template #after> + <gl-button + :disabled="isCommitButtonEnabled" + :loading="fileExistsCheckInProgress || loading" + category="primary" + variant="confirm" + @click="commit" + > + {{ $options.i18n.commitButtonLabel }} + </gl-button> + </template> + </step-nav> + </gl-form> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_wizard/components/editor.vue b/app/assets/javascripts/pipeline_wizard/components/editor.vue new file mode 100644 index 00000000000..41611233f71 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/editor.vue @@ -0,0 +1,94 @@ +<script> +import { debounce } from 'lodash'; +import { isDocument } from 'yaml'; +import { CONTENT_UPDATE_DEBOUNCE } from '~/editor/constants'; +import SourceEditor from '~/editor/source_editor'; +import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; + +export default { + name: 'YamlEditor', + props: { + doc: { + type: Object, + required: true, + validator: (d) => isDocument(d), + }, + highlight: { + type: [String, Array], + required: false, + default: null, + }, + filename: { + type: String, + required: true, + }, + }, + data() { + return { + editor: null, + isUpdating: false, + yamlEditorExtension: null, + }; + }, + watch: { + doc: { + handler() { + this.updateEditorContent(); + }, + deep: true, + }, + highlight(v) { + this.requestHighlight(v); + }, + }, + mounted() { + this.editor = new SourceEditor().createInstance({ + el: this.$el, + blobPath: this.filename, + language: 'yaml', + }); + [, this.yamlEditorExtension] = this.editor.use([ + { definition: SourceEditorExtension }, + { + definition: YamlEditorExtension, + setupOptions: { + highlightPath: this.highlight, + }, + }, + ]); + this.editor.onDidChangeModelContent( + debounce(() => this.handleChange(), CONTENT_UPDATE_DEBOUNCE), + ); + this.updateEditorContent(); + this.emitValue(); + }, + methods: { + async updateEditorContent() { + this.isUpdating = true; + this.editor.setDoc(this.doc); + this.isUpdating = false; + this.requestHighlight(this.highlight); + }, + handleChange() { + this.emitValue(); + if (!this.isUpdating) { + this.handleTouch(); + } + }, + emitValue() { + this.$emit('update:yaml', this.editor.getValue()); + }, + handleTouch() { + this.$emit('touch'); + }, + requestHighlight(path) { + this.editor.highlight(path, true); + }, + }, +}; +</script> + +<template> + <div id="source-editor-yaml-editor"></div> +</template> diff --git a/app/assets/javascripts/pipeline_wizard/components/step_nav.vue b/app/assets/javascripts/pipeline_wizard/components/step_nav.vue new file mode 100644 index 00000000000..8f9198855c6 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/step_nav.vue @@ -0,0 +1,54 @@ +<script> +import { GlButton } from '@gitlab/ui'; + +export default { + name: 'StepNav', + components: { + GlButton, + }, + props: { + showBackButton: { + type: Boolean, + required: false, + default: false, + }, + showNextButton: { + type: Boolean, + required: false, + default: false, + }, + nextButtonEnabled: { + type: Boolean, + required: false, + default: true, + }, + }, +}; +</script> + +<template> + <div> + <slot name="before"></slot> + <gl-button + v-if="showBackButton" + category="secondary" + data-testid="back-button" + @click="$emit('back')" + > + {{ __('Back') }} + </gl-button> + <gl-button + v-if="showNextButton" + :disabled="!nextButtonEnabled" + category="primary" + data-testid="next-button" + variant="confirm" + @click="$emit('next')" + > + {{ __('Next') }} + </gl-button> + <slot name="after"></slot> + </div> +</template> + +<style scoped></style> diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue new file mode 100644 index 00000000000..26235b20ce9 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue @@ -0,0 +1,126 @@ +<script> +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { s__ } from '~/locale'; + +const VALIDATION_STATE = { + NO_VALIDATION: null, + INVALID: false, + VALID: true, +}; + +export default { + name: 'TextWidget', + components: { + GlFormGroup, + GlFormInput, + }, + props: { + label: { + type: String, + required: true, + }, + description: { + type: String, + required: false, + default: null, + }, + placeholder: { + type: String, + required: false, + default: null, + }, + invalidFeedback: { + type: String, + required: false, + default: s__('PipelineWizardInputValidation|This value is not valid'), + }, + id: { + type: String, + required: false, + default: () => uniqueId('textWidget-'), + }, + pattern: { + type: String, + required: false, + default: null, + }, + validate: { + type: Boolean, + required: false, + default: false, + }, + required: { + type: Boolean, + required: false, + default: false, + }, + default: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + touched: false, + value: this.default, + }; + }, + computed: { + validationState() { + if (!this.showValidationState) return VALIDATION_STATE.NO_VALIDATION; + if (this.isRequiredButEmpty) return VALIDATION_STATE.INVALID; + return this.needsValidationAndPasses ? VALIDATION_STATE.VALID : VALIDATION_STATE.INVALID; + }, + showValidationState() { + return this.touched || this.validate; + }, + isRequiredButEmpty() { + return this.required && !this.value; + }, + needsValidationAndPasses() { + return !this.pattern || new RegExp(this.pattern).test(this.value); + }, + invalidFeedbackMessage() { + return this.isRequiredButEmpty + ? s__('PipelineWizardInputValidation|This field is required') + : this.invalidFeedback; + }, + }, + watch: { + validationState(v) { + this.$emit('update:valid', v); + }, + value(v) { + this.$emit('input', v.trim()); + }, + }, + created() { + if (this.default) { + this.$emit('input', this.value); + } + }, +}; +</script> + +<template> + <div data-testid="text-widget"> + <gl-form-group + :description="description" + :invalid-feedback="invalidFeedbackMessage" + :label="label" + :label-for="id" + :state="validationState" + > + <gl-form-input + :id="id" + v-model="value" + :placeholder="placeholder" + :state="validationState" + type="text" + @blur="touched = true" + /> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_wizard/queries/create_commit.graphql b/app/assets/javascripts/pipeline_wizard/queries/create_commit.graphql new file mode 100644 index 00000000000..9abf8eff587 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/queries/create_commit.graphql @@ -0,0 +1,9 @@ +mutation CreateCommit($input: CommitCreateInput!) { + commitCreate(input: $input) { + commit { + id + } + content + errors + } +} diff --git a/app/assets/javascripts/pipeline_wizard/queries/get_file_meta.graphql b/app/assets/javascripts/pipeline_wizard/queries/get_file_meta.graphql new file mode 100644 index 00000000000..87f014fade6 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/queries/get_file_meta.graphql @@ -0,0 +1,12 @@ +query GetFileMetadata($fullPath: ID!, $filePath: String!, $ref: String) { + project(fullPath: $fullPath) { + id + repository { + blobs(paths: [$filePath], ref: $ref) { + nodes { + id + } + } + } + } +} 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 12c3f9a7f40..795ba91a164 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -59,7 +59,11 @@ export default { </script> <template> <!-- eslint-disable @gitlab/vue-no-data-toggle --> - <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright"> + <div + :id="computedJobId" + class="ci-job-dropdown-container dropdown dropright" + data-qa-selector="job_dropdown_container" + > <button type="button" data-toggle="dropdown" @@ -79,7 +83,10 @@ export default { </div> </button> - <ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown"> + <ul + class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown" + data-qa-selector="jobs_dropdown_menu" + > <li class="scrollable-menu"> <ul> <li v-for="job in group.jobs" :key="job.id"> diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index ee58dcc4882..795b95421c7 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -2,7 +2,7 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; -import { sprintf } from '~/locale'; +import { sprintf, __ } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { reportToSentry } from '../../utils'; import ActionComponent from '../jobs_shared/action_component.vue'; @@ -160,6 +160,21 @@ export default { hasAction() { return this.job.status && this.job.status.action && this.job.status.action.path; }, + hasUnauthorizedManualAction() { + return ( + !this.hasAction && + this.job.status?.group === 'manual' && + this.job.status?.label?.includes('(not allowed)') + ); + }, + unauthorizedManualActionIcon() { + /* + The action object is not available when the user cannot run the action. + So we can show the correct icon, extract the action name from the label instead: + "manual play action (not allowed)" or "manual stop action (not allowed)" + */ + return this.job.status?.label?.split(' ')[1]; + }, relatedDownstreamHovered() { return this.job.name === this.sourceJobHovered; }, @@ -198,6 +213,9 @@ export default { this.$emit('pipelineActionRequestComplete'); }, }, + i18n: { + unauthorizedTooltip: __('You are not authorized to run this manual job'), + }, }; </script> <template> @@ -242,8 +260,16 @@ export default { :link="status.action.path" :action-icon="status.action.icon" class="gl-mr-1" - data-qa-selector="action_button" + data-qa-selector="job_action_button" @pipelineActionRequestComplete="pipelineActionRequestComplete" /> + <action-component + v-if="hasUnauthorizedManualAction" + disabled + :tooltip-text="$options.i18n.unauthorizedTooltip" + :action-icon="unauthorizedManualActionIcon" + :link="`unauthorized-${computedJobId}`" + class="gl-mr-1" + /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index e0c1dcc5be5..c59f56fc68f 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui'; +import { GlBadge, GlButton, GlLink, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; @@ -12,10 +12,10 @@ export default { }, components: { CiStatus, + GlBadge, GlButton, GlLink, GlLoadingIcon, - GlBadge, }, props: { columnTitle: { @@ -26,6 +26,10 @@ export default { type: Boolean, required: true, }, + isLoading: { + type: Boolean, + required: true, + }, pipeline: { type: Object, required: true, @@ -34,33 +38,40 @@ export default { type: String, required: true, }, - isLoading: { - type: Boolean, - required: true, - }, }, computed: { - tooltipText() { - return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} - - ${this.sourceJobInfo}`; + buttonBorderClass() { + return this.isUpstream ? 'gl-border-r-1!' : 'gl-border-l-1!'; }, buttonId() { return `js-linked-pipeline-${this.pipeline.id}`; }, - pipelineStatus() { - return this.pipeline.status; + cardSpacingClass() { + return this.isDownstream ? 'gl-pr-0' : ''; }, - projectName() { - return this.pipeline.project.name; + expandedIcon() { + if (this.isUpstream) { + return this.expanded ? 'angle-right' : 'angle-left'; + } + return this.expanded ? 'angle-left' : 'angle-right'; + }, + childPipeline() { + return this.isDownstream && this.isSameProject; }, downstreamTitle() { return this.childPipeline ? this.sourceJobName : this.pipeline.project.name; }, - parentPipeline() { - return this.isUpstream && this.isSameProject; + flexDirection() { + return this.isUpstream ? 'gl-flex-direction-row-reverse' : 'gl-flex-direction-row'; }, - childPipeline() { - return this.isDownstream && this.isSameProject; + isDownstream() { + return this.type === DOWNSTREAM; + }, + isSameProject() { + return !this.pipeline.multiproject; + }, + isUpstream() { + return this.type === UPSTREAM; }, label() { if (this.parentPipeline) { @@ -70,17 +81,17 @@ export default { } return __('Multi-project'); }, + parentPipeline() { + return this.isUpstream && this.isSameProject; + }, pipelineIsLoading() { return Boolean(this.isLoading || this.pipeline.isLoading); }, - isDownstream() { - return this.type === DOWNSTREAM; - }, - isUpstream() { - return this.type === UPSTREAM; + pipelineStatus() { + return this.pipeline.status; }, - isSameProject() { - return !this.pipeline.multiproject; + projectName() { + return this.pipeline.project.name; }, sourceJobName() { return this.pipeline.sourceJob?.name ?? ''; @@ -88,28 +99,23 @@ export default { sourceJobInfo() { return this.isDownstream ? sprintf(__('Created by %{job}'), { job: this.sourceJobName }) : ''; }, - expandedIcon() { - if (this.isUpstream) { - return this.expanded ? 'angle-right' : 'angle-left'; - } - return this.expanded ? 'angle-left' : 'angle-right'; - }, - expandButtonPosition() { - return this.isUpstream ? 'gl-left-0 gl-border-r-1!' : 'gl-right-0 gl-border-l-1!'; + tooltipText() { + return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} - + ${this.sourceJobInfo}`; }, }, errorCaptured(err, _vm, info) { reportToSentry('linked_pipeline', `error: ${err}, info: ${info}`); }, methods: { + hideTooltips() { + this.$root.$emit(BV_HIDE_TOOLTIP); + }, onClickLinkedPipeline() { this.hideTooltips(); this.$emit('pipelineClicked', this.$refs.linkedPipeline); this.$emit('pipelineExpandToggle', this.sourceJobName, !this.expanded); }, - hideTooltips() { - this.$root.$emit(BV_HIDE_TOOLTIP); - }, onDownstreamHovered() { this.$emit('downstreamHovered', this.sourceJobName); }, @@ -124,27 +130,23 @@ export default { <div ref="linkedPipeline" v-gl-tooltip - class="gl-downstream-pipeline-job-width" + class="gl-h-full gl-display-flex! gl-border-solid gl-border-gray-100 gl-border-1" + :class="flexDirection" :title="tooltipText" data-qa-selector="child_pipeline" @mouseover="onDownstreamHovered" @mouseleave="onDownstreamHoverLeave" > - <div - class="gl-relative gl-bg-white gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1" - :class="{ 'gl-pl-9': isUpstream }" - > - <div class="gl-display-flex gl-pr-7 gl-pipeline-job-width"> + <div class="gl-w-full gl-bg-white gl-p-3" :class="cardSpacingClass"> + <div class="gl-display-flex gl-pr-3"> <ci-status v-if="!pipelineIsLoading" :status="pipelineStatus" :size="24" css-classes="gl-top-0 gl-pr-2" /> - <div v-else class="gl-pr-2"><gl-loading-icon size="sm" inline /></div> - <div - class="gl-display-flex gl-flex-direction-column gl-pipeline-job-width gl-text-truncate" - > + <div v-else class="gl-pr-3"><gl-loading-icon size="sm" inline /></div> + <div class="gl-display-flex gl-flex-direction-column gl-downstream-pipeline-job-width"> <span class="gl-text-truncate" data-testid="downstream-title"> {{ downstreamTitle }} </span> @@ -160,10 +162,12 @@ export default { {{ label }} </gl-badge> </div> + </div> + <div class="gl-display-flex"> <gl-button :id="buttonId" - class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!" - :class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`" + class="gl-shadow-none! gl-rounded-0!" + :class="`js-pipeline-expand-${pipeline.id} ${buttonBorderClass}`" :icon="expandedIcon" :aria-label="__('Expand pipeline')" data-testid="expand-pipeline-button" diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 8088858f381..6a4d1bb44f2 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,9 +1,22 @@ <script> -import { GlAlert, GlButton, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; +import { + GlAlert, + GlButton, + GlLoadingIcon, + GlModal, + GlModalDirective, + GlTooltipDirective, +} from '@gitlab/ui'; import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import ciHeader from '~/vue_shared/components/header_ci_component.vue'; -import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants'; +import { + LOAD_FAILURE, + POST_FAILURE, + DELETE_FAILURE, + DEFAULT, + BUTTON_TOOLTIP_RETRY, +} from '../constants'; import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql'; import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql'; import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql'; @@ -15,6 +28,7 @@ const POLL_INTERVAL = 10000; export default { name: 'PipelineHeaderSection', + BUTTON_TOOLTIP_RETRY, pipelineCancel: 'pipelineCancel', pipelineRetry: 'pipelineRetry', finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'], @@ -27,6 +41,7 @@ export default { }, directives: { GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, }, errorTexts: { [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'), @@ -225,6 +240,9 @@ export default { > <gl-button v-if="canRetryPipeline" + v-gl-tooltip + :aria-label="$options.BUTTON_TOOLTIP_RETRY" + :title="$options.BUTTON_TOOLTIP_RETRY" :loading="isRetrying" :disabled="isRetrying" category="secondary" diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue index e11073aee33..99fb5c146ba 100644 --- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue +++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue @@ -36,10 +36,13 @@ export default { return data.project?.pipeline?.jobs?.nodes || []; }, result({ data }) { + if (!data) { + return; + } this.jobsPageInfo = data.project?.pipeline?.jobs?.pageInfo || {}; }, error() { - createFlash({ message: __('An error occured while fetching the pipelines jobs.') }); + createFlash({ message: __('An error occurred while fetching the pipelines jobs.') }); }, }, }, diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue index efad43ddd4f..ca2537ca4f4 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue @@ -92,14 +92,20 @@ export default { <template> <gl-button :id="`js-ci-action-${link}`" - v-gl-tooltip="{ boundary: 'viewport' }" - :title="tooltipText" :class="cssClass" :disabled="isDisabled" class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center" + data-testid="ci-action-component" @click.stop="onClickAction" > - <gl-loading-icon v-if="isLoading" size="sm" class="js-action-icon-loading" /> - <gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" /> + <div + v-gl-tooltip.viewport + :title="tooltipText" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-h-full" + data-testid="ci-action-icon-tooltip-wrapper" + > + <gl-loading-icon v-if="isLoading" size="sm" class="js-action-icon-loading" /> + <gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" /> + </div> </gl-button> </template> diff --git a/app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue b/app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue new file mode 100644 index 00000000000..b8f9f84c217 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue @@ -0,0 +1,102 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import getPipelineWarnings from '../../graphql/queries/get_pipeline_warnings.query.graphql'; + +export default { + // eslint-disable-next-line @gitlab/require-i18n-strings + expectedMessage: 'will be removed in', + i18n: { + title: __('Found warning in your .gitlab-ci.yml'), + rootTypesWarning: __( + '%{codeStart}types%{codeEnd} is deprecated and will be removed in 15.0. Use %{codeStart}stages%{codeEnd} instead. %{linkStart}Learn More %{linkEnd}', + ), + typeWarning: __( + '%{codeStart}type%{codeEnd} is deprecated and will be removed in 15.0. Use %{codeStart}stage%{codeEnd} instead. %{linkStart}Learn More %{linkEnd}', + ), + }, + components: { + GlAlert, + GlLink, + GlSprintf, + }, + inject: ['deprecatedKeywordsDocPath', 'fullPath', 'pipelineIid'], + apollo: { + warnings: { + query: getPipelineWarnings, + variables() { + return { + fullPath: this.fullPath, + iid: this.pipelineIid, + }; + }, + update(data) { + return data?.project?.pipeline?.warningMessages || []; + }, + error() { + this.hasError = true; + }, + }, + }, + data() { + return { + warnings: [], + hasError: false, + }; + }, + computed: { + deprecationWarnings() { + return this.warnings.filter(({ content }) => { + return content.includes(this.$options.expectedMessage); + }); + }, + formattedWarnings() { + // The API doesn't have a mechanism currently to return a + // type instead of just the error message. To work around this, + // we check if the deprecation message is found within the warnings + // and show a FE version of that message with the link to the documentation + // and translated. We can have only 2 types of warnings: root types and individual + // type. If the word `root` is present, then we know it's the root type deprecation + // and if not, it's the normal type. This has to be deleted in 15.0. + // Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/350810 + return this.deprecationWarnings.map(({ content }) => { + if (content.includes('root')) { + return this.$options.i18n.rootTypesWarning; + } + return this.$options.i18n.typeWarning; + }); + }, + hasDeprecationWarning() { + return this.formattedWarnings.length > 0; + }, + showWarning() { + return ( + !this.$apollo.queries.warnings?.loading && !this.hasError && this.hasDeprecationWarning + ); + }, + }, +}; +</script> +<template> + <div> + <gl-alert + v-if="showWarning" + :title="$options.i18n.title" + variant="warning" + :dismissible="false" + > + <ul class="gl-mb-0"> + <li v-for="warning in formattedWarnings" :key="warning"> + <gl-sprintf :message="warning"> + <template #code="{ content }"> + <code> {{ content }}</code> + </template> + <template #link="{ content }"> + <gl-link :href="deprecatedKeywordsDocPath" target="_blank"> {{ content }}</gl-link> + </template> + </gl-sprintf> + </li> + </ul> + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue index b6c178d20b0..fa0e153b2af 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue @@ -1,15 +1,13 @@ <script> import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; import eventHub from '../../event_hub'; +import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '../../constants'; import PipelineMultiActions from './pipeline_multi_actions.vue'; import PipelinesManualActions from './pipelines_manual_actions.vue'; export default { - i18n: { - cancelTitle: __('Cancel'), - redeployTitle: __('Retry'), - }, + BUTTON_TOOLTIP_RETRY, + BUTTON_TOOLTIP_CANCEL, directives: { GlTooltip: GlTooltipDirective, GlModalDirective, @@ -75,12 +73,13 @@ export default { <gl-button v-if="pipeline.flags.retryable" v-gl-tooltip.hover - :aria-label="$options.i18n.redeployTitle" - :title="$options.i18n.redeployTitle" + :aria-label="$options.BUTTON_TOOLTIP_RETRY" + :title="$options.BUTTON_TOOLTIP_RETRY" :disabled="isRetrying" :loading="isRetrying" class="js-pipelines-retry-button" data-qa-selector="pipeline_retry_button" + data-testid="pipelines-retry-button" icon="repeat" variant="default" category="secondary" @@ -91,14 +90,15 @@ export default { v-if="pipeline.flags.cancelable" v-gl-tooltip.hover v-gl-modal-directive="'confirmation-modal'" - :aria-label="$options.i18n.cancelTitle" - :title="$options.i18n.cancelTitle" + :aria-label="$options.BUTTON_TOOLTIP_CANCEL" + :title="$options.BUTTON_TOOLTIP_CANCEL" :loading="isCancelling" :disabled="isCancelling" icon="cancel" variant="danger" category="primary" class="js-pipelines-cancel-button gl-ml-1" + data-testid="pipelines-cancel-button" @click="handleCancelClick" /> 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 0528e4c147c..b29c8426301 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue @@ -26,7 +26,7 @@ export default { v-if="user" :link-href="user.path" :img-src="user.avatar_url" - :img-size="26" + :img-size="32" :tooltip-text="user.name" class="gl-ml-3 js-pipeline-url-user" /> 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 e2f30d5a8e6..52da4d01468 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -1,15 +1,19 @@ <script> -import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui'; +import { GlIcon, GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { SCHEDULE_ORIGIN } from '../../constants'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { SCHEDULE_ORIGIN, ICONS } from '../../constants'; export default { components: { + GlIcon, GlLink, GlPopover, GlSprintf, GlBadge, + TooltipOnTruncate, }, directives: { GlTooltip: GlTooltipDirective, @@ -33,11 +37,12 @@ export default { type: String, required: true, }, + viewType: { + type: String, + required: true, + }, }, computed: { - user() { - return this.pipeline.user; - }, isScheduled() { return this.pipeline.source === SCHEDULE_ORIGIN; }, @@ -53,12 +58,160 @@ export default { autoDevopsHelpPath() { return helpPagePath('topics/autodevops/index.md'); }, + mergeRequestRef() { + return this.pipeline?.merge_request; + }, + commitRef() { + return this.pipeline?.ref; + }, + commitTag() { + return this.commitRef?.tag; + }, + commitUrl() { + return this.pipeline?.commit?.commit_path; + }, + commitShortSha() { + return this.pipeline?.commit?.short_id; + }, + refUrl() { + return this.commitRef?.ref_url || this.commitRef?.path; + }, + tooltipTitle() { + return this.mergeRequestRef?.title || this.commitRef?.name; + }, + commitAuthor() { + let commitAuthorInformation; + const pipelineCommit = this.pipeline?.commit; + const pipelineCommitAuthor = pipelineCommit?.author; + + if (!pipelineCommit) { + return null; + } + + // 1. person who is an author of a commit might be a GitLab user + if (pipelineCommitAuthor) { + // 2. if person who is an author of a commit is a GitLab user + // they can have a GitLab avatar + if (pipelineCommitAuthor?.avatar_url) { + commitAuthorInformation = pipelineCommitAuthor; + + // 3. If GitLab user does not have avatar, they might have a Gravatar + } else if (pipelineCommit.author_gravatar_url) { + commitAuthorInformation = { + ...pipelineCommitAuthor, + avatar_url: pipelineCommit.author_gravatar_url, + }; + } + // 4. If committer is not a GitLab User, they can have a Gravatar + } else { + commitAuthorInformation = { + avatar_url: pipelineCommit.author_gravatar_url, + path: `mailto:${pipelineCommit.author_email}`, + username: pipelineCommit.author_name, + }; + } + + return commitAuthorInformation; + }, + commitIcon() { + let name = ''; + + if (this.commitTag) { + name = ICONS.TAG; + } else if (this.mergeRequestRef) { + name = ICONS.MR; + } else { + name = ICONS.BRANCH; + } + + return name; + }, + commitIconTooltipTitle() { + switch (this.commitIcon) { + case ICONS.TAG: + return __('Tag'); + case ICONS.MR: + return __('Merge Request'); + default: + return __('Branch'); + } + }, + commitTitleText() { + return this.pipeline?.commit?.title || __("Can't find HEAD commit for this branch"); + }, + hasAuthor() { + return ( + this.commitAuthor?.avatar_url && this.commitAuthor?.path && this.commitAuthor?.username + ); + }, + userImageAltDescription() { + return this.commitAuthor?.username + ? sprintf(__("%{username}'s avatar"), { username: this.commitAuthor.username }) + : null; + }, + rearrangePipelinesTable() { + return this.glFeatures?.rearrangePipelinesTable; + }, }, }; </script> <template> <div class="pipeline-tags" data-testid="pipeline-url-table-cell"> + <template v-if="rearrangePipelinesTable"> + <div class="commit-title gl-mb-2" data-testid="commit-title-container"> + <span class="gl-display-flex"> + <tooltip-on-truncate :title="commitTitleText" class="flex-truncate-child gl-flex-grow-1"> + <gl-link + :href="pipeline.path" + class="commit-row-message gl-text-blue-600!" + data-testid="commit-title" + data-qa-selector="pipeline_url_link" + >{{ commitTitleText }}</gl-link + > + </tooltip-on-truncate> + </span> + </div> + <div class="gl-mb-2"> + <span class="gl-font-weight-bold gl-text-gray-500" data-testid="pipeline-identifier"> + #{{ pipeline[pipelineKey] }} + </span> + <!--Commit row--> + <div class="icon-container gl-display-inline-block"> + <gl-icon + v-gl-tooltip + :name="commitIcon" + :title="commitIconTooltipTitle" + data-testid="commit-icon-type" + /> + </div> + <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top"> + <gl-link + v-if="mergeRequestRef" + :href="mergeRequestRef.path" + class="ref-name" + data-testid="merge-request-ref" + >{{ mergeRequestRef.iid }}</gl-link + > + <gl-link v-else :href="refUrl" class="ref-name" data-testid="commit-ref-name">{{ + commitRef.name + }}</gl-link> + </tooltip-on-truncate> + <gl-icon + v-gl-tooltip + name="commit" + class="commit-icon" + :title="__('Commit')" + data-testid="commit-icon" + /> + + <gl-link :href="commitUrl" class="commit-sha mr-0" data-testid="commit-short-sha">{{ + commitShortSha + }}</gl-link> + <!--End of commit row--> + </div> + </template> <gl-link + v-if="!rearrangePipelinesTable" :href="pipeline.path" class="gl-text-decoration-underline" data-testid="pipeline-url-link" @@ -66,7 +219,7 @@ export default { > #{{ pipeline[pipelineKey] }} </gl-link> - <div class="label-container"> + <div class="label-container gl-mt-1"> <gl-badge v-if="isScheduled" v-gl-tooltip @@ -163,7 +316,7 @@ export default { v-gl-tooltip :title=" __( - 'Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results.', + 'Merge request pipelines are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for merge request pipelines.', ) " variant="info" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue index b94f1a42039..47fffa8a6b2 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue @@ -2,6 +2,7 @@ import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { s__, __, sprintf } from '~/locale'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import eventHub from '../../event_hub'; @@ -28,7 +29,7 @@ export default { }; }, methods: { - onClickAction(action) { + async onClickAction(action) { if (action.scheduled_at) { const confirmationMessage = sprintf( s__( @@ -36,9 +37,10 @@ export default { ), { jobName: action.name }, ); - // https://gitlab.com/gitlab-org/gitlab-foss/issues/52156 - // eslint-disable-next-line no-alert - if (!window.confirm(confirmationMessage)) { + + const confirmed = await confirmAction(confirmationMessage); + + if (!confirmed) { return; } } diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue index f56457a4162..54901c2d13f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue @@ -3,12 +3,16 @@ import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.v import { PIPELINE_STATUSES } from '~/code_quality_walkthrough/constants'; import { CHILD_VIEW } from '~/pipelines/constants'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import PipelinesTimeago from './time_ago.vue'; export default { components: { CodeQualityWalkthrough, CiBadge, + PipelinesTimeago, }, + mixins: [glFeatureFlagsMixin()], props: { pipeline: { type: Object, @@ -40,6 +44,9 @@ export default { codeQualityBuildPath() { return this.pipeline?.details?.code_quality_build_path; }, + rearrangePipelinesTable() { + return this.glFeatures?.rearrangePipelinesTable; + }, }, }; </script> @@ -48,11 +55,13 @@ export default { <div> <ci-badge id="js-code-quality-walkthrough" + class="gl-mb-3" :status="pipelineStatus" :show-text="!isChildView" :icon-classes="'gl-vertical-align-middle!'" data-qa-selector="pipeline_commit_status" /> + <pipelines-timeago v-if="rearrangePipelinesTable" class="gl-mt-3" :pipeline="pipeline" /> <code-quality-walkthrough v-if="shouldRenderCodeQualityWalkthrough" :step="codeQualityStep" 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 d64decc81ec..9919a18cb99 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -1,6 +1,7 @@ <script> import { GlTableLite, GlTooltipDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../../event_hub'; import PipelineMiniGraph from './pipeline_mini_graph.vue'; import PipelineOperations from './pipeline_operations.vue'; @@ -33,6 +34,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagMixin()], props: { pipelines: { type: Array, @@ -72,16 +74,18 @@ export default { key: 'status', label: s__('Pipeline|Status'), thClass: DEFAULT_TH_CLASSES, - columnClass: 'gl-w-10p', + columnClass: this.rearrangePipelinesTable ? 'gl-w-15p' : 'gl-w-10p', tdClass: DEFAULT_TD_CLASS, thAttr: { 'data-testid': 'status-th' }, }, { key: 'pipeline', - label: this.pipelineKeyOption.label, + label: this.rearrangePipelinesTable ? __('Pipeline') : this.pipelineKeyOption.label, thClass: DEFAULT_TH_CLASSES, - tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, - columnClass: 'gl-w-10p', + tdClass: this.rearrangePipelinesTable + ? `${DEFAULT_TD_CLASS}` + : `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, + columnClass: this.rearrangePipelinesTable ? 'gl-w-30p' : 'gl-w-10p', thAttr: { 'data-testid': 'pipeline-th' }, }, { @@ -113,7 +117,7 @@ export default { label: s__('Pipeline|Duration'), thClass: DEFAULT_TH_CLASSES, tdClass: DEFAULT_TD_CLASS, - columnClass: 'gl-w-15p', + columnClass: this.rearrangePipelinesTable ? 'gl-w-5p' : 'gl-w-15p', thAttr: { 'data-testid': 'timeago-th' }, }, { @@ -124,7 +128,13 @@ export default { thAttr: { 'data-testid': 'actions-th' }, }, ]; - return fields; + + return !this.rearrangePipelinesTable + ? fields + : fields.filter((field) => !['commit', 'timeago'].includes(field.key)); + }, + rearrangePipelinesTable() { + return this.glFeatures?.rearrangePipelinesTable; }, }, watch: { @@ -182,6 +192,7 @@ export default { :pipeline="item" :pipeline-schedule-url="pipelineScheduleUrl" :pipeline-key="pipelineKeyOption.key" + :view-type="viewType" /> </template> 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 e6b03751350..c45e3f24567 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -54,11 +54,14 @@ export default { showSkipped() { return !this.duration && !this.finishedTime && this.skipped; }, + shouldDisplayAsBlock() { + return this.glFeatures?.rearrangePipelinesTable; + }, }, }; </script> <template> - <div> + <div class="{ 'gl-display-block': shouldDisplayAsBlock }"> <span v-if="showInProgress" data-testid="pipeline-in-progress"> <gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" /> <gl-icon @@ -87,6 +90,7 @@ export default { <time v-gl-tooltip :title="tooltipTitle(finishedTime)" + :datetime="finishedTime" data-placement="top" data-container="body" > diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 410fc7b82cd..36f708ff2af 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -10,6 +10,12 @@ export const SCHEDULE_ORIGIN = 'schedule'; export const NEEDS_PROPERTY = 'needs'; export const EXPLICIT_NEEDS_PROPERTY = 'previousStageJobsOrNeeds'; +export const ICONS = { + TAG: 'tag', + MR: 'git-merge', + BRANCH: 'branch', +}; + export const TestStatus = { FAILED: 'failed', SKIPPED: 'skipped', @@ -53,3 +59,6 @@ export const PipelineKeyOptions = [ ]; export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.'); + +export const BUTTON_TOOLTIP_RETRY = __('Retry failed jobs'); +export const BUTTON_TOOLTIP_CANCEL = __('Cancel'); diff --git a/app/assets/javascripts/pipelines/graphql/fragmentTypes.json b/app/assets/javascripts/pipelines/graphql/fragmentTypes.json deleted file mode 100644 index 4601b74b5c1..00000000000 --- a/app/assets/javascripts/pipelines/graphql/fragmentTypes.json +++ /dev/null @@ -1 +0,0 @@ -{"__schema":{"types":[{"kind":"UNION","name":"JobNeedUnion","possibleTypes":[{"name":"CiBuildNeed"},{"name":"CiJob"}]}]}}
\ No newline at end of file diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql new file mode 100644 index 00000000000..cd1d2b62a3d --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql @@ -0,0 +1,12 @@ +query getPipelineWarnings($fullPath: ID!, $iid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + warningMessages { + content + id + } + } + } +} diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js index 3201f88a9e3..c4f7665c91d 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js @@ -1,6 +1,7 @@ import Visibility from 'visibilityjs'; import createFlash from '~/flash'; import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; import { validateParams } from '~/pipelines/utils'; @@ -195,11 +196,20 @@ export default { this.$toast.show(TOAST_MESSAGE); this.updateTable(); }) - .catch(() => { + .catch((e) => { + const unauthorized = e.response.status === httpStatusCodes.UNAUTHORIZED; + const badRequest = e.response.status === httpStatusCodes.BAD_REQUEST; + + let errorMessage = __( + 'An error occurred while trying to run a new pipeline for this merge request.', + ); + + if (unauthorized || badRequest) { + errorMessage = __('You do not have permission to run a pipeline on this branch.'); + } + createFlash({ - message: __( - 'An error occurred while trying to run a new pipeline for this merge request.', - ), + message: errorMessage, }); }) .finally(() => this.store.toggleIsRunningPipeline(false)); diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index ae8b2503c79..bfb95e5ab0c 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -3,6 +3,7 @@ import { __ } from '~/locale'; import createDagApp from './pipeline_details_dag'; import { createPipelinesDetailApp } from './pipeline_details_graph'; import { createPipelineHeaderApp } from './pipeline_details_header'; +import { createPipelineNotificationApp } from './pipeline_details_notification'; import { createPipelineJobsApp } from './pipeline_details_jobs'; import { apolloProvider } from './pipeline_shared_client'; import { createTestDetails } from './pipeline_test_details'; @@ -11,6 +12,7 @@ const SELECTORS = { PIPELINE_DETAILS: '.js-pipeline-details-vue', PIPELINE_GRAPH: '#js-pipeline-graph-vue', PIPELINE_HEADER: '#js-pipeline-header-vue', + PIPELINE_NOTIFICATION: '#js-pipeline-notification', PIPELINE_TESTS: '#js-pipeline-tests-detail', PIPELINE_JOBS: '#js-pipeline-jobs-vue', }; @@ -43,6 +45,14 @@ export default async function initPipelineDetailsBundle() { } try { + createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider); + } catch { + createFlash({ + message: __('An error occurred while loading a section of this page.'), + }); + } + + try { createDagApp(apolloProvider); } catch { createFlash({ diff --git a/app/assets/javascripts/pipelines/pipeline_details_notification.js b/app/assets/javascripts/pipelines/pipeline_details_notification.js new file mode 100644 index 00000000000..0061be843c5 --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_notification.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import DeprecatedKeywordNotification from './components/notification/deprecated_type_keyword_notification.vue'; + +Vue.use(VueApollo); + +export const createPipelineNotificationApp = (elSelector, apolloProvider) => { + const el = document.querySelector(elSelector); + + if (!el) { + return; + } + + const { deprecatedKeywordsDocPath, fullPath, pipelineIid } = el?.dataset; + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + DeprecatedKeywordNotification, + }, + provide: { + deprecatedKeywordsDocPath, + fullPath, + pipelineIid, + }, + apolloProvider, + render(createElement) { + return createElement('deprecated-keyword-notification'); + }, + }); +}; diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/pipelines/pipeline_shared_client.js index 84276588d6a..c3be487caae 100644 --- a/app/assets/javascripts/pipelines/pipeline_shared_client.js +++ b/app/assets/javascripts/pipelines/pipeline_shared_client.js @@ -1,19 +1,10 @@ import VueApollo from 'vue-apollo'; -import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import createDefaultClient from '~/lib/graphql'; -import introspectionQueryResultData from './graphql/fragmentTypes.json'; - -export const fragmentMatcher = new IntrospectionFragmentMatcher({ - introspectionQueryResultData, -}); export const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( {}, { - cacheConfig: { - fragmentMatcher, - }, useGet: true, }, ), diff --git a/app/assets/javascripts/popovers/index.js b/app/assets/javascripts/popovers/index.js index 7db669b8c52..94340ae16a0 100644 --- a/app/assets/javascripts/popovers/index.js +++ b/app/assets/javascripts/popovers/index.js @@ -13,7 +13,7 @@ const getPopoversApp = () => { document.body.appendChild(container); const Popovers = Vue.extend(PopoversComponent); - app = new Popovers(); + app = new Popovers({ name: 'PopoversRoot' }); app.$mount(`#${APP_ELEMENT_ID}`); } diff --git a/app/assets/javascripts/projects/components/project_delete_button.vue b/app/assets/javascripts/projects/components/project_delete_button.vue index eaf93e2da4f..924b6f55db4 100644 --- a/app/assets/javascripts/projects/components/project_delete_button.vue +++ b/app/assets/javascripts/projects/components/project_delete_button.vue @@ -1,12 +1,8 @@ <script> -import { GlAlert, GlSprintf } from '@gitlab/ui'; -import { __ } from '~/locale'; import SharedDeleteButton from './shared/delete_button.vue'; export default { components: { - GlSprintf, - GlAlert, SharedDeleteButton, }, props: { @@ -39,66 +35,17 @@ export default { required: true, }, }, - strings: { - alertTitle: __('You are about to permanently delete this project'), - alertBody: __( - 'After a project is permanently deleted, it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd}, including issues, merge requests etc.', - ), - isNotForkMessage: __( - 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:', - ), - isForkMessage: __('This forked project has the following:'), - }, }; </script> <template> - <shared-delete-button v-bind="{ confirmPhrase, formPath }"> - <template #modal-body> - <gl-alert - class="gl-mb-5" - variant="danger" - :title="$options.strings.alertTitle" - :dismissible="false" - > - <p> - <gl-sprintf v-if="isFork" :message="$options.strings.isForkMessage" /> - <gl-sprintf v-else :message="$options.strings.isNotForkMessage"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </p> - <ul> - <li> - <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)"> - <template #issuesCount>{{ issuesCount }}</template> - </gl-sprintf> - </li> - <li> - <gl-sprintf - :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)" - > - <template #mergeRequestsCount>{{ mergeRequestsCount }}</template> - </gl-sprintf> - </li> - <li> - <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)"> - <template #forksCount>{{ forksCount }}</template> - </gl-sprintf> - </li> - <li> - <gl-sprintf :message="n__('%d star', '%d stars', starsCount)"> - <template #starsCount>{{ starsCount }}</template> - </gl-sprintf> - </li> - </ul> - <gl-sprintf :message="$options.strings.alertBody"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </gl-alert> - </template> - </shared-delete-button> + <shared-delete-button + :confirm-phrase="confirmPhrase" + :form-path="formPath" + :is-fork="isFork" + :issues-count="issuesCount" + :merge-requests-count="mergeRequestsCount" + :forks-count="forksCount" + :stars-count="starsCount" + /> </template> diff --git a/app/assets/javascripts/projects/components/shared/delete_button.vue b/app/assets/javascripts/projects/components/shared/delete_button.vue index 2e46f437ace..fd71a246a26 100644 --- a/app/assets/javascripts/projects/components/shared/delete_button.vue +++ b/app/assets/javascripts/projects/components/shared/delete_button.vue @@ -1,14 +1,16 @@ <script> -import { GlModal, GlModalDirective, GlFormInput, GlButton } from '@gitlab/ui'; +import { GlModal, GlModalDirective, GlFormInput, GlButton, GlAlert, GlSprintf } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import csrf from '~/lib/utils/csrf'; import { __ } from '~/locale'; export default { components: { + GlAlert, GlModal, GlFormInput, GlButton, + GlSprintf, }, directives: { GlModal: GlModalDirective, @@ -22,6 +24,26 @@ export default { type: String, required: true, }, + isFork: { + type: Boolean, + required: true, + }, + issuesCount: { + type: Number, + required: true, + }, + mergeRequestsCount: { + type: Number, + required: true, + }, + forksCount: { + type: Number, + required: true, + }, + starsCount: { + type: Number, + required: true, + }, }, data() { return { @@ -55,8 +77,17 @@ export default { }, strings: { deleteProject: __('Delete project'), - title: __('Delete project. Are you ABSOLUTELY SURE?'), - confirmText: __('Please type the following to confirm:'), + title: __('Are you absolutely sure?'), + confirmText: __('Enter the following to confirm:'), + isForkAlertTitle: __('You are about to delete this forked project containing:'), + isNotForkAlertTitle: __('You are about to delete this project containing:'), + isForkAlertBody: __('This process deletes the project repository and all related resources.'), + isNotForkAlertBody: __( + 'This project is %{strongStart}NOT%{strongEnd} a fork. This process deletes the project repository and all related resources.', + ), + isNotForkMessage: __( + 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:', + ), }, }; </script> @@ -83,7 +114,52 @@ export default { > <template #modal-title>{{ $options.strings.title }}</template> <div> - <slot name="modal-body"></slot> + <gl-alert class="gl-mb-5" variant="danger" :dismissible="false"> + <h4 v-if="isFork" data-testid="delete-alert-title" class="gl-alert-title"> + {{ $options.strings.isForkAlertTitle }} + </h4> + <h4 v-else data-testid="delete-alert-title" class="gl-alert-title"> + {{ $options.strings.isNotForkAlertTitle }} + </h4> + <ul> + <li> + <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)"> + <template #issuesCount>{{ issuesCount }}</template> + </gl-sprintf> + </li> + <li> + <gl-sprintf + :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)" + > + <template #mergeRequestsCount>{{ mergeRequestsCount }}</template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)"> + <template #forksCount>{{ forksCount }}</template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="n__('%d star', '%d stars', starsCount)"> + <template #starsCount>{{ starsCount }}</template> + </gl-sprintf> + </li> + </ul> + <gl-sprintf + v-if="isFork" + data-testid="delete-alert-body" + :message="$options.strings.isForkAlertBody" + /> + <gl-sprintf + v-else + data-testid="delete-alert-body" + :message="$options.strings.isNotForkAlertBody" + > + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </gl-alert> <p class="gl-mb-1">{{ $options.strings.confirmText }}</p> <p> <code class="gl-white-space-pre-wrap">{{ confirmPhrase }}</code> diff --git a/app/assets/javascripts/projects/new/components/deployment_target_select.vue b/app/assets/javascripts/projects/new/components/deployment_target_select.vue new file mode 100644 index 00000000000..f3b7e39f148 --- /dev/null +++ b/app/assets/javascripts/projects/new/components/deployment_target_select.vue @@ -0,0 +1,61 @@ +<script> +import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { + DEPLOYMENT_TARGET_SELECTIONS, + DEPLOYMENT_TARGET_LABEL, + DEPLOYMENT_TARGET_EVENT, + NEW_PROJECT_FORM, +} from '../constants'; + +const trackingMixin = Tracking.mixin({ label: DEPLOYMENT_TARGET_LABEL }); + +export default { + i18n: { + deploymentTargetLabel: s__('Deployment Target|Project deployment target (optional)'), + defaultOption: s__('Deployment Target|Select the deployment target'), + }, + deploymentTargets: DEPLOYMENT_TARGET_SELECTIONS, + selectId: 'deployment-target-select', + components: { + GlFormGroup, + GlFormSelect, + }, + mixins: [trackingMixin], + data() { + return { + selectedTarget: null, + formSubmitted: false, + }; + }, + mounted() { + const form = document.getElementById(NEW_PROJECT_FORM); + form.addEventListener('submit', () => { + this.formSubmitted = true; + this.trackSelection(); + }); + }, + methods: { + trackSelection() { + if (this.formSubmitted && this.selectedTarget) { + this.track(DEPLOYMENT_TARGET_EVENT, { property: this.selectedTarget }); + } + }, + }, +}; +</script> + +<template> + <gl-form-group :label="$options.i18n.deploymentTargetLabel" :label-for="$options.selectId"> + <gl-form-select + :id="$options.selectId" + v-model="selectedTarget" + :options="$options.deploymentTargets" + > + <template #first> + <option :value="null" disabled>{{ $options.i18n.defaultOption }}</option> + </template> + </gl-form-select> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/projects/new/constants.js b/app/assets/javascripts/projects/new/constants.js new file mode 100644 index 00000000000..c5e6722981b --- /dev/null +++ b/app/assets/javascripts/projects/new/constants.js @@ -0,0 +1,20 @@ +import { s__ } from '~/locale'; + +export const DEPLOYMENT_TARGET_SELECTIONS = [ + s__('DeploymentTarget|Kubernetes (GKE, EKS, OpenShift, and so on)'), + s__('DeploymentTarget|Managed container runtime (Fargate, Cloud Run, DigitalOcean App)'), + s__('DeploymentTarget|Self-managed container runtime (Podman, Docker Swarm, Docker Compose)'), + s__('DeploymentTarget|Heroku'), + s__('DeploymentTarget|Virtual machine (for example, EC2)'), + s__('DeploymentTarget|Mobile app store'), + s__('DeploymentTarget|Registry (package or container)'), + s__('DeploymentTarget|Infrastructure provider (Terraform, Cloudformation, and so on)'), + s__('DeploymentTarget|Serverless backend (Lambda, Cloud functions)'), + s__('DeploymentTarget|GitLab Pages'), + s__('DeploymentTarget|Other hosting service'), + s__('DeploymentTarget|No deployment planned'), +]; + +export const NEW_PROJECT_FORM = 'new_project'; +export const DEPLOYMENT_TARGET_LABEL = 'new_project_deployment_target'; +export const DEPLOYMENT_TARGET_EVENT = 'select_deployment_target'; diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js index 010c6a29ae3..4de9b8a6f47 100644 --- a/app/assets/javascripts/projects/new/index.js +++ b/app/assets/javascripts/projects/new/index.js @@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import NewProjectCreationApp from './components/app.vue'; import NewProjectUrlSelect from './components/new_project_url_select.vue'; +import DeploymentTargetSelect from './components/deployment_target_select.vue'; export function initNewProjectCreation() { const el = document.querySelector('.js-new-project-creation'); @@ -64,3 +65,16 @@ export function initNewProjectUrlSelect() { }), ); } + +export function initDeploymentTargetSelect() { + const el = document.querySelector('.js-deployment-target-select'); + + if (!el) { + return null; + } + + return new Vue({ + el, + render: (createElement) => createElement(DeploymentTargetSelect), + }); +} diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 8d71a3dab68..62e2cec874a 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,6 +1,8 @@ import $ from 'jquery'; import { debounce } from 'lodash'; import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '../lib/utils/constants'; import axios from '../lib/utils/axios_utils'; import { convertToTitleCase, @@ -13,20 +15,26 @@ let hasUserDefinedProjectPath = false; let hasUserDefinedProjectName = false; const invalidInputClass = 'gl-field-error-outline'; +const cancelSource = axios.CancelToken.source(); +const endpoint = `${gon.relative_url_root}/import/url/validate`; +let importCredentialsValidationPromise = null; const validateImportCredentials = (url, user, password) => { - const endpoint = `${gon.relative_url_root}/import/url/validate`; - return axios - .post(endpoint, { - url, - user, - password, - }) + cancelSource.cancel(); + importCredentialsValidationPromise = axios + .post(endpoint, { url, user, password }, { cancelToken: cancelSource.cancel() }) .then(({ data }) => data) - .catch(() => ({ - // intentionally reporting success in case of validation error - // we do not want to block users from trying import in case of validation exception - success: true, - })); + .catch((thrown) => + axios.isCancel(thrown) + ? { + cancelled: true, + } + : { + // intentionally reporting success in case of validation error + // we do not want to block users from trying import in case of validation exception + success: true, + }, + ); + return importCredentialsValidationPromise; }; const onProjectNameChange = ($projectNameInput, $projectPathInput) => { @@ -72,7 +80,7 @@ const deriveProjectPathFromUrl = ($projectImportUrl) => { .parents('.toggle-import-form') .find('#project_path'); - if (hasUserDefinedProjectPath) { + if (hasUserDefinedProjectPath || $currentProjectPath.length === 0) { return; } @@ -98,6 +106,21 @@ const deriveProjectPathFromUrl = ($projectImportUrl) => { }; const bindHowToImport = () => { + const importLinks = document.querySelectorAll('.js-how-to-import-link'); + + importLinks.forEach((link) => { + const { modalTitle: title, modalMessage: modalHtmlMessage } = link.dataset; + + link.addEventListener('click', (e) => { + e.preventDefault(); + confirmAction('', { + modalHtmlMessage, + title, + hideCancel: true, + }); + }); + }); + $('.how_to_import_link').on('click', (e) => { e.preventDefault(); $(e.currentTarget).next('.modal').show(); @@ -114,7 +137,7 @@ const bindEvents = () => { const $projectImportUrlUser = $('#project_import_url_user'); const $projectImportUrlPassword = $('#project_import_url_password'); const $projectImportUrlError = $('.js-import-url-error'); - const $projectImportForm = $('.project-import form'); + const $projectImportForm = $('form.js-project-import'); const $projectPath = $('.tab-pane.active #project_path'); const $useTemplateBtn = $('.template-button > input'); const $projectFieldsForm = $('.project-fields-form'); @@ -124,7 +147,7 @@ const bindEvents = () => { const $projectTemplateButtons = $('.project-templates-buttons'); const $projectName = $('.tab-pane.active #project_name'); - if ($newProjectForm.length !== 1) { + if ($newProjectForm.length !== 1 && $projectImportForm.length !== 1) { return; } @@ -168,20 +191,28 @@ const bindEvents = () => { $projectPath.val($projectPath.val().trim()); }); - const updateUrlPathWarningVisibility = debounce(async () => { - const { success: isUrlValid } = await validateImportCredentials( + const updateUrlPathWarningVisibility = async () => { + const { success: isUrlValid, cancelled } = await validateImportCredentials( $projectImportUrl.val(), $projectImportUrlUser.val(), $projectImportUrlPassword.val(), ); + if (cancelled) { + return; + } + $projectImportUrl.toggleClass(invalidInputClass, !isUrlValid); $projectImportUrlError.toggleClass('hide', isUrlValid); - }, 500); + }; + const debouncedUpdateUrlPathWarningVisibility = debounce( + updateUrlPathWarningVisibility, + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, + ); let isProjectImportUrlDirty = false; $projectImportUrl.on('blur', () => { isProjectImportUrlDirty = true; - updateUrlPathWarningVisibility(); + debouncedUpdateUrlPathWarningVisibility(); }); $projectImportUrl.on('keyup', () => { deriveProjectPathFromUrl($projectImportUrl); @@ -190,17 +221,33 @@ const bindEvents = () => { [$projectImportUrl, $projectImportUrlUser, $projectImportUrlPassword].forEach(($f) => { $f.on('input', () => { if (isProjectImportUrlDirty) { - updateUrlPathWarningVisibility(); + debouncedUpdateUrlPathWarningVisibility(); } }); }); - $projectImportForm.on('submit', (e) => { + $projectImportForm.on('submit', async (e) => { + e.preventDefault(); + + if (importCredentialsValidationPromise === null) { + // we didn't validate credentials yet + debouncedUpdateUrlPathWarningVisibility.cancel(); + updateUrlPathWarningVisibility(); + } + + const submitBtn = $projectImportForm.find('input[type="submit"]'); + + submitBtn.disable(); + await importCredentialsValidationPromise; + submitBtn.enable(); + const $invalidFields = $projectImportForm.find(`.${invalidInputClass}`); if ($invalidFields.length > 0) { $invalidFields[0].focus(); - e.preventDefault(); - e.stopPropagation(); + } else { + // calling .submit() on HTMLFormElement does not trigger 'submit' event + // We are using this behavior to bypass this handler and avoid infinite loop + $projectImportForm[0].submit(); } }); diff --git a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue index 91d8fca0487..aa3235b1515 100644 --- a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue +++ b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue @@ -2,6 +2,7 @@ import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { __, s__ } from '~/locale'; +import { CC_VALIDATION_REQUIRED_ERROR } from '../constants'; const DEFAULT_ERROR_MESSAGE = __('An error occurred while updating the configuration.'); const REQUIRES_VALIDATION_TEXT = s__( @@ -47,11 +48,13 @@ export default { }; }, computed: { - showCreditCardValidation() { + ccRequiredError() { + return this.errorMessage === CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed; + }, + genericError() { return ( - this.isCreditCardValidationRequired && - !this.isSharedRunnerEnabled && - !this.successfulValidation && + this.errorMessage && + this.errorMessage !== CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed ); }, @@ -62,6 +65,7 @@ export default { }, toggleSharedRunners() { this.isLoading = true; + this.ccAlertDismissed = false; this.errorMessage = null; axios @@ -82,20 +86,19 @@ export default { <template> <div> <section class="gl-mt-5"> - <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" :dismissible="false"> - {{ errorMessage }} - </gl-alert> - <cc-validation-required-alert - v-if="showCreditCardValidation" + v-if="ccRequiredError" class="gl-pb-5" :custom-message="$options.i18n.REQUIRES_VALIDATION_TEXT" @verifiedCreditCard="creditCardValidated" @dismiss="ccAlertDismissed = true" /> + <gl-alert v-if="genericError" class="gl-mb-3" variant="danger" :dismissible="false"> + {{ errorMessage }} + </gl-alert> + <gl-toggle - v-else ref="sharedRunnersToggle" :disabled="isDisabledAndUnoverridable" :is-loading="isLoading" diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue index b98e1101884..fe968e74c6d 100644 --- a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue +++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue @@ -11,8 +11,12 @@ export default { ConfirmDanger, }, props: { - namespaces: { - type: Object, + groupNamespaces: { + type: Array, + required: true, + }, + userNamespaces: { + type: Array, required: true, }, confirmationPhrase: { @@ -44,10 +48,10 @@ export default { <div> <gl-form-group> <namespace-select - class="qa-namespaces-list" data-testid="transfer-project-namespace" :full-width="true" - :data="namespaces" + :group-namespaces="groupNamespaces" + :user-namespaces="userNamespaces" :selected-namespace="selectedNamespace" @select="handleSelect" /> diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js index f5591c43dc4..9cf1afd334f 100644 --- a/app/assets/javascripts/projects/settings/constants.js +++ b/app/assets/javascripts/projects/settings/constants.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const LEVEL_TYPES = { ROLE: 'role', USER: 'user', @@ -18,3 +20,8 @@ export const ACCESS_LEVELS = { }; export const ACCESS_LEVEL_NONE = 0; + +// must match shared_runners_setting in update_service.rb +export const CC_VALIDATION_REQUIRED_ERROR = __( + 'Shared runners enabled cannot be enabled until a valid credit card is on file', +); diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js index 47b49031dc9..a5f720bffaa 100644 --- a/app/assets/javascripts/projects/settings/init_transfer_project_form.js +++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js @@ -3,10 +3,14 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import TransferProjectForm from './components/transfer_project_form.vue'; const prepareNamespaces = (rawNamespaces = '') => { + if (!rawNamespaces) { + return { groupNamespaces: [], userNamespaces: [] }; + } + const data = JSON.parse(rawNamespaces); return { - group: data?.group.map(convertObjectPropsToCamelCase), - user: data?.user.map(convertObjectPropsToCamelCase), + groupNamespaces: data?.group?.map(convertObjectPropsToCamelCase) || [], + userNamespaces: data?.user?.map(convertObjectPropsToCamelCase) || [], }; }; @@ -35,7 +39,7 @@ export default () => { props: { confirmButtonText, confirmationPhrase, - namespaces: prepareNamespaces(namespaces), + ...prepareNamespaces(namespaces), }, on: { selectNamespace: (id) => { 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 f936c03c5d3..9ee2e7a4ffd 100644 --- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -9,6 +9,8 @@ import { linkedIssueTypesMap, addRelatedIssueErrorMap, addRelatedItemErrorMap, + issuablesFormCategoryHeaderTextMap, + issuablesFormInputTextMap, } from '../constants'; import RelatedIssuableInput from './related_issuable_input.vue'; @@ -134,6 +136,12 @@ export default { epics: mergeUrlParams({ confidential_only: true }, this.autoCompleteSources.epics), }; }, + issuableCategoryHeaderText() { + return issuablesFormCategoryHeaderTextMap[this.issuableType]; + }, + issuableInputText() { + return issuablesFormInputTextMap[this.issuableType]; + }, }, methods: { onPendingIssuableRemoveRequest(params) { @@ -162,7 +170,7 @@ export default { <form @submit.prevent="onFormSubmit"> <template v-if="showCategorizedIssues"> <gl-form-group - :label="__('The current issue')" + :label="issuableCategoryHeaderText" label-for="linked-issue-type-radio" label-class="label-bold" class="mb-2" @@ -175,7 +183,7 @@ export default { /> </gl-form-group> <p class="bold"> - {{ __('the following issue(s)') }} + {{ issuableInputText }} </p> </template> <related-issuable-input diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue index 94535e1b8c9..bc97fab9ad2 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -5,6 +5,9 @@ import { issuableQaClassMap, linkedIssueTypesMap, linkedIssueTypesTextMap, + issuablesBlockHeaderTextMap, + issuablesBlockHelpTextMap, + issuablesBlockAddButtonTextMap, } from '../constants'; import AddIssuableForm from './add_issuable_form.vue'; import RelatedIssuesList from './related_issues_list.vue'; @@ -105,6 +108,15 @@ export default { hasBody() { return this.isFormVisible || this.shouldShowTokenBody; }, + headerText() { + return issuablesBlockHeaderTextMap[this.issuableType]; + }, + helpLinkText() { + return issuablesBlockHelpTextMap[this.issuableType]; + }, + addIssuableButtonText() { + return issuablesBlockAddButtonTextMap[this.issuableType]; + }, badgeLabel() { return this.isFetching && this.relatedIssues.length === 0 ? '...' : this.relatedIssues.length; }, @@ -138,13 +150,14 @@ export default { href="#related-issues" aria-hidden="true" /> - <slot name="header-text">{{ __('Linked issues') }}</slot> + <slot name="header-text">{{ headerText }}</slot> <gl-link v-if="hasHelpPath" :href="helpPath" target="_blank" class="gl-display-flex gl-align-items-center gl-ml-2 gl-text-gray-500" - :aria-label="__('Read more about related issues')" + data-testid="help-link" + :aria-label="helpLinkText" > <gl-icon name="question" :size="12" /> </gl-link> @@ -160,7 +173,7 @@ export default { v-if="canAdmin" data-qa-selector="related_issues_plus_button" icon="plus" - :aria-label="__('Add a related issue')" + :aria-label="addIssuableButtonText" :class="qaClass" @click="$emit('toggleAddRelatedIssuesForm', $event)" /> diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js index 89eae069a24..f911468d8f1 100644 --- a/app/assets/javascripts/related_issues/constants.js +++ b/app/assets/javascripts/related_issues/constants.js @@ -104,3 +104,28 @@ export const PathIdSeparator = { Epic: '&', Issue: '#', }; + +export const issuablesBlockHeaderTextMap = { + [issuableTypesMap.ISSUE]: __('Linked issues'), + [issuableTypesMap.EPIC]: __('Linked epics'), +}; + +export const issuablesBlockHelpTextMap = { + [issuableTypesMap.ISSUE]: __('Read more about related issues'), + [issuableTypesMap.EPIC]: __('Read more about related epics'), +}; + +export const issuablesBlockAddButtonTextMap = { + [issuableTypesMap.ISSUE]: __('Add a related issue'), + [issuableTypesMap.EPIC]: __('Add a related epic'), +}; + +export const issuablesFormCategoryHeaderTextMap = { + [issuableTypesMap.ISSUE]: __('The current issue'), + [issuableTypesMap.EPIC]: __('The current epic'), +}; + +export const issuablesFormInputTextMap = { + [issuableTypesMap.ISSUE]: __('the following issue(s)'), + [issuableTypesMap.EPIC]: __('the following epic(s)'), +}; diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js index 0ee99df1455..35858be90b2 100644 --- a/app/assets/javascripts/related_issues/index.js +++ b/app/assets/javascripts/related_issues/index.js @@ -8,6 +8,7 @@ export default function initRelatedIssues() { // eslint-disable-next-line no-new new Vue({ el: relatedIssuesRootElement, + name: 'RelatedIssuesRoot', components: { relatedIssuesRoot: RelatedIssuesRoot, }, diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue index ed4f3c4e0fe..05ab5c2cc90 100644 --- a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue +++ b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue @@ -1,9 +1,10 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlBadge, GlLink } from '@gitlab/ui'; export default { name: 'AccessibilityIssueBody', components: { + GlBadge, GlLink, }, props: { @@ -38,9 +39,9 @@ export default { <template> <div class="report-block-list-issue-description gl-mt-2 gl-mb-2"> <div ref="accessibility-issue-description" class="report-block-list-issue-description-text"> - <div v-if="isNew" ref="accessibility-issue-is-new-badge" class="badge badge-danger gl-mr-2"> - {{ s__('AccessibilityReport|New') }} - </div> + <gl-badge v-if="isNew" class="gl-mr-2" variant="danger">{{ + s__('AccessibilityReport|New') + }}</gl-badge> <div> {{ sprintf( diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 9368d7e6058..52963b49f68 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -9,12 +9,14 @@ import axios from '~/lib/utils/axios_utils'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import { redirectTo } from '~/lib/utils/url_utility'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getRefMixin from '../mixins/get_ref'; import blobInfoQuery from '../queries/blob_info.query.graphql'; +import { DEFAULT_BLOB_INFO, TEXT_FILE_TYPE, LFS_STORAGE } from '../constants'; import BlobButtonGroup from './blob_button_group.vue'; import BlobEdit from './blob_edit.vue'; import ForkSuggestion from './fork_suggestion.vue'; -import { loadViewer, viewerProps } from './blob_viewers'; +import { loadViewer } from './blob_viewers'; export default { i18n: { @@ -29,7 +31,7 @@ export default { GlButton, ForkSuggestion, }, - mixins: [getRefMixin], + mixins: [getRefMixin, glFeatureFlagMixin()], inject: { originalBranch: { default: '', @@ -43,12 +45,11 @@ export default { projectPath: this.projectPath, filePath: this.path, ref: this.originalBranch || this.ref, + shouldFetchRawText: Boolean(this.glFeatures.highlightJs), }; }, result() { - this.switchViewer( - this.hasRichViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER, - ); + this.switchViewer(this.hasRichViewer ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER); }, error() { this.displayError(); @@ -78,50 +79,7 @@ export default { isBinary: false, isLoadingLegacyViewer: false, activeViewerType: SIMPLE_BLOB_VIEWER, - project: { - userPermissions: { - pushCode: false, - downloadCode: false, - createMergeRequestIn: false, - forkProject: false, - }, - pathLocks: { - nodes: [], - }, - repository: { - empty: true, - blobs: { - nodes: [ - { - name: '', - size: '', - rawTextBlob: '', - type: '', - fileType: '', - tooLarge: false, - path: '', - editBlobPath: '', - ideEditPath: '', - forkAndEditPath: '', - ideForkAndEditPath: '', - storedExternally: false, - externalStorage: '', - canModifyBlob: false, - canCurrentUserPushToBranch: false, - archived: false, - rawPath: '', - externalStorageUrl: '', - replacePath: '', - pipelineEditorPath: '', - deletePath: '', - simpleViewer: {}, - richViewer: null, - webPath: '', - }, - ], - }, - }, - }, + project: DEFAULT_BLOB_INFO, }; }, computed: { @@ -132,7 +90,7 @@ export default { return this.$apollo.queries.project.loading; }, isBinaryFileType() { - return this.isBinary || this.blobInfo.simpleViewer?.fileType !== 'text'; + return this.isBinary || this.blobInfo.simpleViewer?.fileType !== TEXT_FILE_TYPE; }, blobInfo() { const nodes = this.project?.repository?.blobs?.nodes || []; @@ -151,11 +109,16 @@ export default { }, blobViewer() { const { fileType } = this.viewer; - return loadViewer(fileType); + return this.shouldLoadLegacyViewer ? null : loadViewer(fileType, this.isUsingLfs); }, - viewerProps() { - const { fileType } = this.viewer; - return viewerProps(fileType, this.blobInfo); + shouldLoadLegacyViewer() { + return this.viewer.fileType === TEXT_FILE_TYPE && !this.glFeatures.highlightJs; + }, + legacyViewerLoaded() { + return ( + (this.activeViewerType === SIMPLE_BLOB_VIEWER && this.legacySimpleViewer) || + (this.activeViewerType === RICH_BLOB_VIEWER && this.legacyRichViewer) + ); }, canLock() { const { pushCode, downloadCode } = this.project.userPermissions; @@ -183,18 +146,23 @@ export default { ? this.blobInfo.ideForkAndEditPath : this.blobInfo.forkAndEditPath; }, + isUsingLfs() { + return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE; + }, }, methods: { - loadLegacyViewer(type) { - if (this.legacyViewerLoaded(type)) { + loadLegacyViewer() { + if (this.legacyViewerLoaded) { return; } + const type = this.activeViewerType; + this.isLoadingLegacyViewer = true; axios .get(`${this.blobInfo.webPath}?format=json&viewer=${type}`) .then(({ data: { html, binary } }) => { - if (type === 'simple') { + if (type === SIMPLE_BLOB_VIEWER) { this.legacySimpleViewer = html; } else { this.legacyRichViewer = html; @@ -205,12 +173,6 @@ export default { }) .catch(() => this.displayError()); }, - legacyViewerLoaded(type) { - return ( - (type === SIMPLE_BLOB_VIEWER && this.legacySimpleViewer) || - (type === RICH_BLOB_VIEWER && this.legacyRichViewer) - ); - }, displayError() { createFlash({ message: __('An error occurred while loading the file. Please try again.') }); }, @@ -218,7 +180,7 @@ export default { this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER; if (!this.blobViewer) { - this.loadLegacyViewer(this.activeViewerType); + this.loadLegacyViewer(); } }, editBlob(target) { @@ -243,10 +205,11 @@ export default { <div v-if="blobInfo && !isLoading" class="file-holder"> <blob-header :blob="blobInfo" - :hide-viewer-switcher="!hasRichViewer || isBinaryFileType" + :hide-viewer-switcher="!hasRichViewer || isBinaryFileType || isUsingLfs" :is-binary="isBinaryFileType" :active-viewer-type="viewer.type" :has-render-error="hasRenderError" + :show-path="false" @viewer-changed="switchViewer" > <template #actions> @@ -303,7 +266,7 @@ export default { :hide-line-numbers="true" :loading="isLoadingLegacyViewer" /> - <component :is="blobViewer" v-else v-bind="viewerProps" class="blob-viewer" /> + <component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" /> </div> </div> </template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue index 48fa33eb558..f7b318c64d9 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue @@ -9,19 +9,17 @@ export default { GlLink, }, props: { - fileName: { - type: String, + blob: { + type: Object, required: true, }, - filePath: { - type: String, - required: true, - }, - fileSize: { - type: Number, - required: false, - default: 0, - }, + }, + data() { + return { + fileName: this.blob.name, + filePath: this.blob.rawPath, + fileSize: this.blob.rawSize || 0, + }; }, computed: { downloadFileSize() { diff --git a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue index 83d36209bb3..5027f7877aa 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue @@ -1,15 +1,17 @@ <script> export default { props: { - url: { - type: String, - required: true, - }, - alt: { - type: String, + blob: { + type: Object, required: true, }, }, + data() { + return { + url: this.blob.rawPath, + alt: this.blob.name, + }; + }, }; </script> <template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js index 8f6f2d15215..e942f59e7d8 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/index.js +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -1,48 +1,19 @@ -export const loadViewer = (type) => { - switch (type) { - case 'empty': - return () => import(/* webpackChunkName: 'blob_empty_viewer' */ './empty_viewer.vue'); - case 'text': - return gon.features.highlightJs - ? () => - import( - /* webpackChunkName: 'blob_text_viewer' */ '~/vue_shared/components/source_viewer.vue' - ) - : null; - case 'download': - return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue'); - case 'image': - return () => import(/* webpackChunkName: 'blob_image_viewer' */ './image_viewer.vue'); - case 'video': - return () => import(/* webpackChunkName: 'blob_video_viewer' */ './video_viewer.vue'); - case 'pdf': - return () => import(/* webpackChunkName: 'blob_pdf_viewer' */ './pdf_viewer.vue'); - default: - return null; - } +const viewers = { + download: () => import('./download_viewer.vue'), + image: () => import('./image_viewer.vue'), + video: () => import('./video_viewer.vue'), + empty: () => import('./empty_viewer.vue'), + text: () => import('~/vue_shared/components/source_viewer/source_viewer.vue'), + pdf: () => import('./pdf_viewer.vue'), + lfs: () => import('./lfs_viewer.vue'), }; -export const viewerProps = (type, blob) => { - return { - text: { - content: blob.rawTextBlob, - autoDetect: true, // We'll eventually disable autoDetect and pass the language explicitly to reduce the footprint (https://gitlab.com/gitlab-org/gitlab/-/issues/348145) - }, - download: { - fileName: blob.name, - filePath: blob.rawPath, - fileSize: blob.rawSize, - }, - image: { - url: blob.rawPath, - alt: blob.name, - }, - video: { - url: blob.rawPath, - }, - pdf: { - url: blob.rawPath, - fileSize: blob.rawSize, - }, - }[type]; +export const loadViewer = (type, isUsingLfs) => { + let viewer = viewers[type]; + + if (!viewer && isUsingLfs) { + viewer = viewers.lfs; + } + + return viewer; }; diff --git a/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue new file mode 100644 index 00000000000..6dc7e10662e --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue @@ -0,0 +1,38 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + i18n: { + lfsText: __( + 'This content could not be displayed because it is stored in LFS. You can %{linkStart}download it%{linkEnd} instead.', + ), + }, + components: { + GlLink, + GlSprintf, + }, + props: { + blob: { + type: Object, + required: true, + }, + }, + data() { + return { + fileName: this.blob.name, + filePath: this.blob.rawPath, + }; + }, +}; +</script> + +<template> + <div class="gl-text-center gl-py-13 gl-bg-gray-50" data-type="lfs"> + <gl-sprintf :message="$options.i18n.lfsText"> + <template #link="{ content }"> + <gl-link :href="filePath" :download="fileName" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue index 803a357df52..c3df5984426 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue @@ -11,17 +11,17 @@ export default { tooLargeButtonText: __('Download PDF'), }, props: { - url: { - type: String, - required: true, - }, - fileSize: { - type: Number, + blob: { + type: Object, required: true, }, }, data() { - return { totalPages: 0 }; + return { + url: this.blob.rawPath, + fileSize: this.blob.rawSize, + totalPages: 0, + }; }, computed: { tooLargeToDisplay() { diff --git a/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue index dec0c4802ca..260b831f4d1 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue @@ -1,11 +1,16 @@ <script> export default { props: { - url: { - type: String, + blob: { + type: Object, required: true, }, }, + data() { + return { + url: this.blob.rawPath, + }; + }, }; </script> <template> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 43e114a91d3..c3d121505b6 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -139,8 +139,10 @@ export default { /> <gl-button v-if="commit.descriptionHtml" + v-gl-tooltip :class="{ open: showDescription }" - :aria-label="__('Show commit description')" + :title="__('Toggle commit description')" + :aria-label="__('Toggle commit description')" class="text-expander gl-vertical-align-bottom!" icon="ellipsis_h" @click="toggleShowDescription" diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue index fb0e505a16e..8a081944600 100644 --- a/app/assets/javascripts/repository/components/table/parent_row.vue +++ b/app/assets/javascripts/repository/components/table/parent_row.vue @@ -1,10 +1,13 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; export default { components: { GlLoadingIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { commitRef: { type: String, @@ -41,7 +44,13 @@ export default { <template> <tr class="tree-item"> - <td colspan="3" class="tree-item-file-name" @click.self="clickRow"> + <td + v-gl-tooltip.left.viewport + :title="__('Go to parent directory')" + colspan="3" + class="tree-item-file-name" + @click.self="clickRow" + > <gl-loading-icon v-if="parentPath === loadingPath" size="sm" diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 8fcec5fb893..7aac35e7613 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -195,6 +195,7 @@ export default { projectPath: this.projectPath, filePath: this.path, ref: this.ref, + shouldFetchRawText: Boolean(this.glFeatures.highlightJs), }); }, apolloQuery(query, variables) { diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index d01757d6141..e206d9bfbd2 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -25,3 +25,54 @@ export const PDF_MAX_FILE_SIZE = 10000000; // 10 MB export const PDF_MAX_PAGE_LIMIT = 50; export const ROW_APPEAR_DELAY = 150; + +export const DEFAULT_BLOB_INFO = { + userPermissions: { + pushCode: false, + downloadCode: false, + createMergeRequestIn: false, + forkProject: false, + }, + pathLocks: { + nodes: [], + }, + repository: { + empty: true, + blobs: { + nodes: [ + { + name: '', + size: '', + rawTextBlob: '', + type: '', + fileType: '', + tooLarge: false, + path: '', + editBlobPath: '', + ideEditPath: '', + forkAndEditPath: '', + ideForkAndEditPath: '', + storedExternally: false, + externalStorage: '', + environmentFormattedExternalUrl: '', + environmentExternalUrlForRouteMap: '', + canModifyBlob: false, + canCurrentUserPushToBranch: false, + archived: false, + rawPath: '', + externalStorageUrl: '', + replacePath: '', + pipelineEditorPath: '', + deletePath: '', + simpleViewer: {}, + richViewer: null, + webPath: '', + }, + ], + }, + }, +}; + +export const TEXT_FILE_TYPE = 'text'; + +export const LFS_STORAGE = 'lfs'; diff --git a/app/assets/javascripts/repository/fragmentTypes.json b/app/assets/javascripts/repository/fragmentTypes.json deleted file mode 100644 index 949ebca432b..00000000000 --- a/app/assets/javascripts/repository/fragmentTypes.json +++ /dev/null @@ -1 +0,0 @@ -{"__schema":{"types":[{"kind":"INTERFACE","name":"Entry","possibleTypes":[{"name":"Blob"},{"name":"Submodule"},{"name":"TreeEntry"}]}]}} diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 96d712ce9b4..29aabe1b00f 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -1,19 +1,11 @@ -import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; -import introspectionQueryResultData from './fragmentTypes.json'; import { fetchLogsTree } from './log_tree'; Vue.use(VueApollo); -// We create a fragment matcher so that we can create a fragment from an interface -// Without this, Apollo throws a heuristic fragment matcher warning -const fragmentMatcher = new IntrospectionFragmentMatcher({ - introspectionQueryResultData, -}); - const defaultClient = createDefaultClient( { Query: { @@ -43,7 +35,6 @@ const defaultClient = createDefaultClient( }, { cacheConfig: { - fragmentMatcher, dataIdFromObject: (obj) => { /* eslint-disable @gitlab/require-i18n-strings */ // eslint-disable-next-line no-underscore-dangle diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index ae20a0f0bc4..78323fdc5f4 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -1,6 +1,11 @@ #import "ee_else_ce/repository/queries/path_locks.fragment.graphql" -query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { +query getBlobInfo( + $projectPath: ID! + $filePath: String! + $ref: String! + $shouldFetchRawText: Boolean! +) { project(fullPath: $projectPath) { userPermissions { pushCode @@ -18,18 +23,22 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { name size rawSize - rawTextBlob + rawTextBlob @include(if: $shouldFetchRawText) fileType + language path editBlobPath ideEditPath forkAndEditPath ideForkAndEditPath + environmentFormattedExternalUrl + environmentExternalUrlForRouteMap canModifyBlob canCurrentUserPushToBranch archived storedExternally externalStorage + externalStorageUrl rawPath replacePath pipelineEditorPath diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index ee9533bbec3..009afe03ea6 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -1,7 +1,7 @@ /* eslint-disable func-names, consistent-return, no-param-reassign */ import $ from 'jquery'; -import Cookies from 'js-cookie'; +import { setCookie } from '~/lib/utils/common_utils'; import { hide, fixTitle } from '~/tooltips'; import createFlash from './flash'; import axios from './lib/utils/axios_utils'; @@ -80,7 +80,7 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { hide($this); if (!triggered) { - Cookies.set('collapsed_gutter', $('.right-sidebar').hasClass('right-sidebar-collapsed')); + setCookie('collapsed_gutter', $('.right-sidebar').hasClass('right-sidebar-collapsed')); } }; diff --git a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue new file mode 100644 index 00000000000..2795ddbbbcb --- /dev/null +++ b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue @@ -0,0 +1,77 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import RunnerEditButton from '../components/runner_edit_button.vue'; +import RunnerPauseButton from '../components/runner_pause_button.vue'; +import RunnerHeader from '../components/runner_header.vue'; +import RunnerDetails from '../components/runner_details.vue'; +import { I18N_FETCH_ERROR } from '../constants'; +import getRunnerQuery from '../graphql/get_runner.query.graphql'; +import { captureException } from '../sentry_utils'; + +export default { + name: 'AdminRunnerShowApp', + components: { + RunnerEditButton, + RunnerPauseButton, + RunnerHeader, + RunnerDetails, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runnerId: { + type: String, + required: true, + }, + }, + data() { + return { + runner: null, + }; + }, + apollo: { + runner: { + query: getRunnerQuery, + variables() { + return { + id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + + this.reportToSentry(error); + }, + }, + }, + computed: { + canUpdate() { + return this.runner.userPermissions?.updateRunner; + }, + }, + errorCaptured(error) { + this.reportToSentry(error); + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + }, +}; +</script> +<template> + <div> + <runner-header v-if="runner" :runner="runner"> + <template #actions> + <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" /> + <runner-pause-button v-if="canUpdate" :runner="runner" /> + </template> + </runner-header> + + <runner-details :runner="runner" /> + </div> +</template> diff --git a/app/assets/javascripts/runner/admin_runner_show/index.js b/app/assets/javascripts/runner/admin_runner_show/index.js new file mode 100644 index 00000000000..a781898cf8d --- /dev/null +++ b/app/assets/javascripts/runner/admin_runner_show/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import AdminRunnerShowApp from './admin_runner_show_app.vue'; + +Vue.use(VueApollo); + +export const initAdminRunnerShow = (selector = '#js-admin-runner-show') => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { runnerId } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(AdminRunnerShowApp, { + props: { + runnerId, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index bb2bac531a7..a968d4029f8 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -99,7 +99,10 @@ export default { allRunnersCount: { ...runnersCountSmartQuery, variables() { - return this.countVariables; + return { + ...this.countVariables, + type: null, + }; }, }, instanceRunnersCount: { @@ -276,7 +279,11 @@ export default { </gl-link> </template> </runner-list> - <runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" /> + <runner-pagination + v-model="search.pagination" + class="gl-mt-3" + :page-info="runners.pageInfo" + /> </template> </div> </template> diff --git a/app/assets/javascripts/runner/components/cells/link_cell.vue b/app/assets/javascripts/runner/components/cells/link_cell.vue new file mode 100644 index 00000000000..2843ddbacaf --- /dev/null +++ b/app/assets/javascripts/runner/components/cells/link_cell.vue @@ -0,0 +1,27 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + props: { + href: { + type: String, + required: false, + default: null, + }, + }, + computed: { + component() { + if (this.href) { + return GlLink; + } + return 'span'; + }, + }, +}; +</script> + +<template> + <component :is="component" :href="href" v-bind="$attrs" v-on="$listeners"> + <slot></slot> + </component> +</template> diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue index 0934508c87f..ae9c774f2a2 100644 --- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -1,16 +1,14 @@ <script> import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { __, s__, sprintf } from '~/locale'; +import { s__, sprintf } from '~/locale'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; -import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerEditButton from '../runner_edit_button.vue'; +import RunnerPauseButton from '../runner_pause_button.vue'; import RunnerDeleteModal from '../runner_delete_modal.vue'; -const I18N_EDIT = __('Edit'); -const I18N_PAUSE = __('Pause'); -const I18N_RESUME = __('Resume'); const I18N_DELETE = s__('Runners|Delete runner'); const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); @@ -19,6 +17,8 @@ export default { components: { GlButton, GlButtonGroup, + RunnerEditButton, + RunnerPauseButton, RunnerDeleteModal, }, directives: { @@ -38,20 +38,6 @@ export default { }; }, computed: { - isActive() { - return this.runner.active; - }, - toggleActiveIcon() { - return this.isActive ? 'pause' : 'play'; - }, - toggleActiveTitle() { - if (this.updating) { - // Prevent a "sticky" tooltip: If this button is disabled, - // mouseout listeners don't run leaving the tooltip stuck - return ''; - } - return this.isActive ? I18N_PAUSE : I18N_RESUME; - }, deleteTitle() { if (this.deleting) { // Prevent a "sticky" tooltip: If this button is disabled, @@ -77,35 +63,6 @@ export default { }, }, methods: { - async onToggleActive() { - this.updating = true; - try { - const toggledActive = !this.runner.active; - - const { - data: { - runnerUpdate: { errors }, - }, - } = await this.$apollo.mutate({ - mutation: runnerActionsUpdateMutation, - variables: { - input: { - id: this.runner.id, - active: toggledActive, - }, - }, - }); - - if (errors && errors.length) { - throw new Error(errors.join(' ')); - } - } catch (e) { - this.onError(e); - } finally { - this.updating = false; - } - }, - async onDelete() { // Deleting stays "true" until this row is removed, // should only change back if the operation fails. @@ -147,7 +104,6 @@ export default { captureException({ error, component: this.$options.name }); }, }, - I18N_EDIT, I18N_DELETE, }; </script> @@ -161,23 +117,8 @@ export default { See https://gitlab.com/gitlab-org/gitlab/-/issues/334802 --> - <gl-button - v-if="canUpdate && runner.editAdminUrl" - v-gl-tooltip.hover.viewport="$options.I18N_EDIT" - :href="runner.editAdminUrl" - :aria-label="$options.I18N_EDIT" - icon="pencil" - data-testid="edit-runner" - /> - <gl-button - v-if="canUpdate" - v-gl-tooltip.hover.viewport="toggleActiveTitle" - :aria-label="toggleActiveTitle" - :icon="toggleActiveIcon" - :loading="updating" - data-testid="toggle-active-runner" - @click="onToggleActive" - /> + <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" /> + <runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" /> <gl-button v-if="canDelete" v-gl-tooltip.hover.viewport="deleteTitle" diff --git a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue index 0e259807f98..54c35e483dc 100644 --- a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue +++ b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue @@ -11,8 +11,10 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants'; export default { name: 'RunnerRegistrationTokenReset', i18n: { - modalTitle: __('Reset registration token'), + modalAction: s__('Runners|Reset token'), + modalCancel: __('Cancel'), modalCopy: __('Are you sure you want to reset the registration token?'), + modalTitle: __('Reset registration token'), }, components: { GlDropdownItem, @@ -30,7 +32,7 @@ export default { default: null, }, }, - modalID: 'token-reset-modal', + modalId: 'token-reset-modal', props: { type: { type: String, @@ -111,10 +113,19 @@ export default { }; </script> <template> - <gl-dropdown-item v-gl-modal="$options.modalID"> + <gl-dropdown-item v-gl-modal="$options.modalId"> {{ __('Reset registration token') }} <gl-modal - :modal-id="$options.modalID" + size="sm" + :modal-id="$options.modalId" + :action-primary="{ + text: $options.i18n.modalAction, + attributes: [{ variant: 'danger' }], + }" + :action-secondary="{ + text: $options.i18n.modalCancel, + attributes: [{ variant: 'default' }], + }" :title="$options.i18n.modalTitle" @primary="handleModalPrimary" > diff --git a/app/assets/javascripts/runner/components/runner_assigned_item.vue b/app/assets/javascripts/runner/components/runner_assigned_item.vue new file mode 100644 index 00000000000..ea8074199a6 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_assigned_item.vue @@ -0,0 +1,39 @@ +<script> +import { GlAvatar, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlAvatar, + GlLink, + }, + props: { + href: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + fullName: { + type: String, + required: true, + }, + avatarUrl: { + type: String, + required: false, + default: null, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center gl-py-5"> + <gl-link :href="href" data-testid="item-avatar" class="gl-text-decoration-none! gl-mr-3"> + <gl-avatar shape="rect" :entity-name="name" :alt="name" :src="avatarUrl" :size="48" /> + </gl-link> + + <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue new file mode 100644 index 00000000000..b1234818b7e --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_detail.vue @@ -0,0 +1,50 @@ +<script> +import { __ } from '~/locale'; + +/** + * Usage: + * + * With a `value` prop: + * + * <runner-detail label="Field Name" :value="value" /> + * + * Or a `value` slot: + * + * <runner-detail label="Field Name"> + * <template #value> + * <strong>{{ value }}</strong> + * </template> + * </runner-detail> + * + */ +export default { + props: { + label: { + type: String, + required: true, + }, + value: { + type: String, + default: null, + required: false, + }, + emptyValue: { + type: String, + default: __('None'), + required: false, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-pb-4"> + <dt class="gl-mr-2">{{ label }}</dt> + <dd class="gl-mb-0"> + <template v-if="value || $slots.value"> + <slot name="value">{{ value }}</slot> + </template> + <span v-else class="gl-text-gray-500">{{ emptyValue }}</span> + </dd> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue new file mode 100644 index 00000000000..b6a5ffc7a64 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_details.vue @@ -0,0 +1,124 @@ +<script> +import { GlBadge, GlTabs, GlTab, GlIntersperse } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; +import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants'; +import { formatJobCount } from '../utils'; +import RunnerDetail from './runner_detail.vue'; +import RunnerGroups from './runner_groups.vue'; +import RunnerProjects from './runner_projects.vue'; +import RunnerJobs from './runner_jobs.vue'; +import RunnerTags from './runner_tags.vue'; + +export default { + components: { + GlBadge, + GlTabs, + GlTab, + GlIntersperse, + RunnerDetail, + RunnerGroups, + RunnerProjects, + RunnerJobs, + RunnerTags, + TimeAgo, + }, + props: { + runner: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + maximumTimeout() { + const { maximumTimeout } = this.runner; + if (typeof maximumTimeout !== 'number') { + return null; + } + return timeIntervalInWords(maximumTimeout); + }, + configTextProtected() { + if (this.runner.accessLevel === ACCESS_LEVEL_REF_PROTECTED) { + return s__('Runners|Protected'); + } + return null; + }, + configTextUntagged() { + if (this.runner.runUntagged) { + return s__('Runners|Runs untagged jobs'); + } + return null; + }, + isGroupRunner() { + return this.runner?.runnerType === GROUP_TYPE; + }, + isProjectRunner() { + return this.runner?.runnerType === PROJECT_TYPE; + }, + jobCount() { + return formatJobCount(this.runner?.jobCount); + }, + }, + ACCESS_LEVEL_REF_PROTECTED, +}; +</script> + +<template> + <gl-tabs> + <gl-tab> + <template #title>{{ s__('Runners|Details') }}</template> + + <template v-if="runner"> + <div class="gl-pt-4"> + <dl class="gl-mb-0" data-testid="runner-details-list"> + <runner-detail :label="s__('Runners|Description')" :value="runner.description" /> + <runner-detail + :label="s__('Runners|Last contact')" + :empty-value="s__('Runners|Never contacted')" + > + <template #value> + <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" /> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|Version')" :value="runner.version" /> + <runner-detail :label="s__('Runners|IP Address')" :value="runner.ipAddress" /> + <runner-detail :label="s__('Runners|Configuration')"> + <template #value> + <gl-intersperse v-if="configTextProtected || configTextUntagged"> + <span v-if="configTextProtected">{{ configTextProtected }}</span> + <span v-if="configTextUntagged">{{ configTextUntagged }}</span> + </gl-intersperse> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" /> + <runner-detail :label="s__('Runners|Tags')"> + <template #value> + <runner-tags + v-if="runner.tagList && runner.tagList.length" + class="gl-vertical-align-middle" + :tag-list="runner.tagList" + size="sm" + /> + </template> + </runner-detail> + </dl> + </div> + + <runner-groups v-if="isGroupRunner" :runner="runner" /> + <runner-projects v-if="isProjectRunner" :runner="runner" /> + </template> + </gl-tab> + <gl-tab> + <template #title> + {{ s__('Runners|Jobs') }} + <gl-badge v-if="jobCount" data-testid="job-count-badge" class="gl-ml-1" size="sm"> + {{ jobCount }} + </gl-badge> + </template> + + <runner-jobs v-if="runner" :runner="runner" /> + </gl-tab> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/runner/components/runner_edit_button.vue b/app/assets/javascripts/runner/components/runner_edit_button.vue new file mode 100644 index 00000000000..b115be09e69 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_edit_button.vue @@ -0,0 +1,26 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const I18N_EDIT = __('Edit'); + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + I18N_EDIT, +}; +</script> + +<template> + <gl-button + v-gl-tooltip="$options.I18N_EDIT" + v-bind="$attrs" + :aria-label="$options.I18N_EDIT" + icon="pencil" + v-on="$listeners" + /> +</template> diff --git a/app/assets/javascripts/runner/components/runner_groups.vue b/app/assets/javascripts/runner/components/runner_groups.vue new file mode 100644 index 00000000000..c3b35bd52a9 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_groups.vue @@ -0,0 +1,37 @@ +<script> +import RunnerAssignedItem from './runner_assigned_item.vue'; + +export default { + components: { + RunnerAssignedItem, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + groups() { + return this.runner.groups?.nodes || []; + }, + }, +}; +</script> + +<template> + <div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid"> + <h3 class="gl-font-lg gl-mt-5 gl-mb-0">{{ s__('Runners|Assigned Group') }}</h3> + <template v-if="groups.length"> + <runner-assigned-item + v-for="group in groups" + :key="group.id" + :href="group.webUrl" + :name="group.name" + :full-name="group.fullName" + :avatar-url="group.avatarUrl" + /> + </template> + <span v-else class="gl-text-gray-500">{{ __('None') }}</span> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_header.vue b/app/assets/javascripts/runner/components/runner_header.vue index 09f58df7bd0..abc07cec1ad 100644 --- a/app/assets/javascripts/runner/components/runner_header.vue +++ b/app/assets/javascripts/runner/components/runner_header.vue @@ -1,19 +1,23 @@ <script> -import { GlSprintf } from '@gitlab/ui'; +import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { sprintf } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { I18N_DETAILS_TITLE } from '../constants'; +import { I18N_DETAILS_TITLE, I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants'; import RunnerTypeBadge from './runner_type_badge.vue'; import RunnerStatusBadge from './runner_status_badge.vue'; export default { components: { + GlIcon, GlSprintf, TimeAgo, RunnerTypeBadge, RunnerStatusBadge, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { runner: { type: Object, @@ -29,24 +33,36 @@ export default { return sprintf(I18N_DETAILS_TITLE, { runner_id: id }); }, }, + I18N_LOCKED_RUNNER_DESCRIPTION, }; </script> <template> - <div class="gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"> - <runner-status-badge :runner="runner" /> - <runner-type-badge v-if="runner" :type="runner.runnerType" /> - <template v-if="runner.createdAt"> - <gl-sprintf :message="__('%{runner} created %{timeago}')"> - <template #runner> - <strong>{{ heading }}</strong> - </template> - <template #timeago> - <time-ago :time="runner.createdAt" /> - </template> - </gl-sprintf> - </template> - <template v-else> - <strong>{{ heading }}</strong> - </template> + <div + class="gl-display-flex gl-align-items-center gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + > + <div> + <runner-status-badge :runner="runner" /> + <runner-type-badge v-if="runner" :type="runner.runnerType" /> + <template v-if="runner.createdAt"> + <gl-sprintf :message="__('%{runner} created %{timeago}')"> + <template #runner> + <strong>{{ heading }}</strong> + <gl-icon + v-if="runner.locked" + v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + name="lock" + :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + /> + </template> + <template #timeago> + <time-ago :time="runner.createdAt" /> + </template> + </gl-sprintf> + </template> + <template v-else> + <strong>{{ heading }}</strong> + </template> + </div> + <div class="gl-ml-auto gl-flex-shrink-0"><slot name="actions"></slot></div> </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue new file mode 100644 index 00000000000..c13e7e90168 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_jobs.vue @@ -0,0 +1,82 @@ +<script> +import { GlSkeletonLoading } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import getRunnerJobsQuery from '../graphql/get_runner_jobs.query.graphql'; +import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants'; +import { captureException } from '../sentry_utils'; +import { getPaginationVariables } from '../utils'; +import RunnerJobsTable from './runner_jobs_table.vue'; +import RunnerPagination from './runner_pagination.vue'; + +export default { + name: 'RunnerJobs', + components: { + GlSkeletonLoading, + RunnerJobsTable, + RunnerPagination, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + data() { + return { + jobs: { + items: [], + pageInfo: {}, + }, + pagination: { + page: 1, + }, + }; + }, + apollo: { + jobs: { + query: getRunnerJobsQuery, + variables() { + return this.variables; + }, + update({ runner }) { + return { + items: runner?.jobs?.nodes || [], + pageInfo: runner?.jobs?.pageInfo || {}, + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + this.reportToSentry(error); + }, + }, + }, + computed: { + variables() { + const { id } = this.runner; + return { + id, + ...getPaginationVariables(this.pagination, RUNNER_DETAILS_JOBS_PAGE_SIZE), + }; + }, + loading() { + return this.$apollo.queries.jobs.loading; + }, + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + }, + I18N_NO_JOBS_FOUND, +}; +</script> + +<template> + <div class="gl-pt-3"> + <gl-skeleton-loading v-if="loading" class="gl-py-5" /> + <runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" /> + <p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p> + + <runner-pagination v-model="pagination" :disabled="loading" :page-info="jobs.pageInfo" /> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_jobs_table.vue b/app/assets/javascripts/runner/components/runner_jobs_table.vue new file mode 100644 index 00000000000..7817577bab0 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_jobs_table.vue @@ -0,0 +1,95 @@ +<script> +import { GlTableLite } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import RunnerTags from '~/runner/components/runner_tags.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { tableField } from '../utils'; +import LinkCell from './cells/link_cell.vue'; + +export default { + components: { + CiBadge, + GlTableLite, + LinkCell, + RunnerTags, + TimeAgo, + }, + props: { + jobs: { + type: Array, + required: true, + }, + }, + methods: { + trAttr(job) { + if (job?.id) { + return { 'data-testid': `job-row-${getIdFromGraphQLId(job.id)}` }; + } + return {}; + }, + jobId(job) { + return getIdFromGraphQLId(job.id); + }, + jobPath(job) { + return job.detailedStatus?.detailsPath; + }, + projectName(job) { + return job.pipeline?.project?.name; + }, + projectWebUrl(job) { + return job.pipeline?.project?.webUrl; + }, + commitShortSha(job) { + return job.shortSha; + }, + commitPath(job) { + return job.commitPath; + }, + }, + fields: [ + tableField({ key: 'status', label: s__('Job|Status') }), + tableField({ key: 'job', label: __('Job') }), + tableField({ key: 'project', label: __('Project') }), + tableField({ key: 'commit', label: __('Commit') }), + tableField({ key: 'finished_at', label: s__('Job|Finished at') }), + tableField({ key: 'tags', label: s__('Runners|Tags') }), + ], +}; +</script> + +<template> + <gl-table-lite + :items="jobs" + :fields="$options.fields" + :tbody-tr-attr="trAttr" + primary-key="id" + stacked="md" + fixed + > + <template #cell(status)="{ item = {} }"> + <ci-badge v-if="item.detailedStatus" :status="item.detailedStatus" /> + </template> + + <template #cell(job)="{ item = {} }"> + <link-cell :href="jobPath(item)"> #{{ jobId(item) }} </link-cell> + </template> + + <template #cell(project)="{ item = {} }"> + <link-cell :href="projectWebUrl(item)">{{ projectName(item) }}</link-cell> + </template> + + <template #cell(commit)="{ item = {} }"> + <link-cell :href="commitPath(item)"> {{ commitShortSha(item) }}</link-cell> + </template> + + <template #cell(tags)="{ item = {} }"> + <runner-tags :tag-list="item.tags" /> + </template> + + <template #cell(finished_at)="{ item = {} }"> + <time-ago v-if="item.finishedAt" :time="item.finishedAt" /> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index 023308dbac2..bb36882d3ae 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -2,31 +2,14 @@ import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { formatNumber, __, s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import { RUNNER_JOB_COUNT_LIMIT } from '../constants'; +import { formatJobCount, tableField } from '../utils'; import RunnerActionsCell from './cells/runner_actions_cell.vue'; import RunnerSummaryCell from './cells/runner_summary_cell.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue'; import RunnerTags from './runner_tags.vue'; -const tableField = ({ key, label = '', thClasses = [] }) => { - return { - key, - label, - thClass: [ - 'gl-bg-transparent!', - 'gl-border-b-solid!', - 'gl-border-b-gray-100!', - 'gl-border-b-1!', - ...thClasses, - ], - tdAttr: { - 'data-testid': `td-${key}`, - }, - }; -}; - export default { components: { GlTable, @@ -54,10 +37,7 @@ export default { }, methods: { formatJobCount(jobCount) { - if (jobCount > RUNNER_JOB_COUNT_LIMIT) { - return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`; - } - return formatNumber(jobCount); + return formatJobCount(jobCount); }, runnerTrAttr(runner) { if (runner) { @@ -70,9 +50,9 @@ export default { }, fields: [ tableField({ key: 'status', label: s__('Runners|Status') }), - tableField({ key: 'summary', label: s__('Runners|Runner ID'), thClasses: ['gl-lg-w-25p'] }), + tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }), tableField({ key: 'version', label: __('Version') }), - tableField({ key: 'ipAddress', label: __('IP Address') }), + tableField({ key: 'ipAddress', label: __('IP') }), tableField({ key: 'jobCount', label: __('Jobs') }), tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }), tableField({ key: 'contactedAt', label: __('Last contact') }), diff --git a/app/assets/javascripts/runner/components/runner_pagination.vue b/app/assets/javascripts/runner/components/runner_pagination.vue index 8645b90f5cd..b683a7f2330 100644 --- a/app/assets/javascripts/runner/components/runner_pagination.vue +++ b/app/assets/javascripts/runner/components/runner_pagination.vue @@ -29,7 +29,14 @@ export default { }, methods: { handlePageChange(page) { - if (page > this.value.page) { + if (page === 1) { + // Small optimization for first page + // If we have loaded using "first", + // page is already cached. + this.$emit('input', { + page, + }); + } else if (page > this.value.page) { this.$emit('input', { page, after: this.pageInfo.endCursor, @@ -47,11 +54,12 @@ export default { <template> <gl-pagination + v-bind="$attrs" :value="value.page" :prev-page="prevPage" :next-page="nextPage" align="center" - class="gl-pagination gl-mt-3" + class="gl-pagination" @input="handlePageChange" /> </template> diff --git a/app/assets/javascripts/runner/components/runner_pause_button.vue b/app/assets/javascripts/runner/components/runner_pause_button.vue new file mode 100644 index 00000000000..a8b259f5b90 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_pause_button.vue @@ -0,0 +1,122 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql'; +import { createAlert } from '~/flash'; +import { captureException } from '~/runner/sentry_utils'; +import { I18N_PAUSE, I18N_RESUME } from '../constants'; + +export default { + name: 'RunnerPauseButton', + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + compact: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + updating: false, + }; + }, + computed: { + isActive() { + return this.runner.active; + }, + icon() { + return this.isActive ? 'pause' : 'play'; + }, + label() { + return this.isActive ? I18N_PAUSE : I18N_RESUME; + }, + buttonContent() { + if (this.compact) { + return null; + } + return this.label; + }, + ariaLabel() { + if (this.compact) { + return this.label; + } + return null; + }, + tooltip() { + // Only show tooltip when compact. + // Also prevent a "sticky" tooltip: If this button is + // disabled, mouseout listeners don't run leaving the tooltip stuck + if (this.compact && !this.updating) { + return this.label; + } + return ''; + }, + }, + methods: { + async onToggle() { + this.updating = true; + try { + const input = { + id: this.runner.id, + active: !this.isActive, + }; + + const { + data: { + runnerUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: runnerToggleActiveMutation, + variables: { + input, + }, + }); + + if (errors && errors.length) { + throw new Error(errors.join(' ')); + } + } catch (e) { + this.onError(e); + } finally { + this.updating = false; + } + }, + onError(error) { + const { message } = error; + createAlert({ message }); + + this.reportToSentry(error); + }, + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip.hover.viewport="tooltip" + v-bind="$attrs" + :aria-label="ariaLabel" + :icon="icon" + :loading="updating" + @click="onToggle" + v-on="$listeners" + > + <!-- + Use <template v-if> to ensure a square button is shown when compact: true. + Sending empty content will still show a distorted/rectangular button. + --> + <template v-if="buttonContent">{{ buttonContent }}</template> + </gl-button> +</template> diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue new file mode 100644 index 00000000000..c4065a24ff2 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_projects.vue @@ -0,0 +1,111 @@ +<script> +import { GlSkeletonLoading } from '@gitlab/ui'; +import { sprintf, formatNumber } from '~/locale'; +import { createAlert } from '~/flash'; +import getRunnerProjectsQuery from '../graphql/get_runner_projects.query.graphql'; +import { + I18N_ASSIGNED_PROJECTS, + I18N_NONE, + I18N_FETCH_ERROR, + RUNNER_DETAILS_PROJECTS_PAGE_SIZE, +} from '../constants'; +import { getPaginationVariables } from '../utils'; +import { captureException } from '../sentry_utils'; +import RunnerAssignedItem from './runner_assigned_item.vue'; +import RunnerPagination from './runner_pagination.vue'; + +export default { + name: 'RunnerProjects', + components: { + GlSkeletonLoading, + RunnerAssignedItem, + RunnerPagination, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + data() { + return { + projects: { + items: [], + pageInfo: {}, + count: 0, + }, + pagination: { + page: 1, + }, + }; + }, + apollo: { + projects: { + query: getRunnerProjectsQuery, + variables() { + return this.variables; + }, + update(data) { + const { runner } = data; + return { + count: runner?.projectCount || 0, + items: runner?.projects?.nodes || [], + pageInfo: runner?.projects?.pageInfo || {}, + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + + this.reportToSentry(error); + }, + }, + }, + computed: { + variables() { + const { id } = this.runner; + return { + id, + ...getPaginationVariables(this.pagination, RUNNER_DETAILS_PROJECTS_PAGE_SIZE), + }; + }, + loading() { + return this.$apollo.queries.projects.loading; + }, + heading() { + return sprintf(I18N_ASSIGNED_PROJECTS, { + projectCount: formatNumber(this.projects.count), + }); + }, + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + }, + I18N_NONE, +}; +</script> + +<template> + <div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid"> + <h3 class="gl-font-lg gl-mt-5 gl-mb-0"> + {{ heading }} + </h3> + + <gl-skeleton-loading v-if="loading" class="gl-py-5" /> + <template v-else-if="projects.items.length"> + <runner-assigned-item + v-for="(project, i) in projects.items" + :key="project.id" + :class="{ 'gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid': i !== 0 }" + :href="project.webUrl" + :name="project.name" + :full-name="project.nameWithNamespace" + :avatar-url="project.avatarUrl" + /> + </template> + <span v-else class="gl-text-gray-500">{{ $options.I18N_NONE }}</span> + + <runner-pagination v-model="pagination" :disabled="loading" :page-info="projects.pageInfo" /> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/runner/components/runner_tags.vue index 8da5e33076f..797d2a35b2c 100644 --- a/app/assets/javascripts/runner/components/runner_tags.vue +++ b/app/assets/javascripts/runner/components/runner_tags.vue @@ -20,7 +20,7 @@ export default { }; </script> <template> - <div> + <span> <runner-tag v-for="tag in tagList" :key="tag" @@ -28,5 +28,5 @@ export default { :tag="tag" :size="size" /> - </div> + </span> </template> diff --git a/app/assets/javascripts/runner/components/runner_type_tabs.vue b/app/assets/javascripts/runner/components/runner_type_tabs.vue index b767dafaccf..25ed6600dc9 100644 --- a/app/assets/javascripts/runner/components/runner_type_tabs.vue +++ b/app/assets/javascripts/runner/components/runner_type_tabs.vue @@ -1,27 +1,21 @@ <script> import { GlTabs, GlTab } from '@gitlab/ui'; -import { s__ } from '~/locale'; import { searchValidator } from '~/runner/runner_search_utils'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_ALL_TYPES, + I18N_INSTANCE_TYPE, + I18N_GROUP_TYPE, + I18N_PROJECT_TYPE, +} from '../constants'; -const tabs = [ - { - title: s__('Runners|All'), - runnerType: null, - }, - { - title: s__('Runners|Instance'), - runnerType: INSTANCE_TYPE, - }, - { - title: s__('Runners|Group'), - runnerType: GROUP_TYPE, - }, - { - title: s__('Runners|Project'), - runnerType: PROJECT_TYPE, - }, -]; +const I18N_TAB_TITLES = { + [INSTANCE_TYPE]: I18N_INSTANCE_TYPE, + [GROUP_TYPE]: I18N_GROUP_TYPE, + [PROJECT_TYPE]: I18N_PROJECT_TYPE, +}; export default { components: { @@ -29,12 +23,34 @@ export default { GlTab, }, props: { + runnerTypes: { + type: Array, + required: false, + default: () => [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE], + }, value: { type: Object, required: true, validator: searchValidator, }, }, + computed: { + tabs() { + const tabs = this.runnerTypes.map((runnerType) => ({ + title: I18N_TAB_TITLES[runnerType], + runnerType, + })); + + // Always add a "All" tab that resets filters + return [ + { + title: I18N_ALL_TYPES, + runnerType: null, + }, + ...tabs, + ]; + }, + }, methods: { onTabSelected({ runnerType }) { this.$emit('input', { @@ -47,13 +63,12 @@ export default { return runnerType === this.value.runnerType; }, }, - tabs, }; </script> <template> <gl-tabs v-bind="$attrs" data-testid="runner-type-tabs"> <gl-tab - v-for="tab in $options.tabs" + v-for="tab in tabs" :key="`${tab.runnerType}`" :active="isTabActive(tab)" @click="onTabSelected(tab)" diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index ce8019ffaa0..1544efaaae2 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -1,13 +1,20 @@ -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; export const RUNNER_PAGE_SIZE = 20; export const RUNNER_JOB_COUNT_LIMIT = 1000; -export const GROUP_RUNNER_COUNT_LIMIT = 1000; + +export const RUNNER_DETAILS_PROJECTS_PAGE_SIZE = 5; +export const RUNNER_DETAILS_JOBS_PAGE_SIZE = 30; export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.'); export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}'); // Type + +export const I18N_ALL_TYPES = s__('Runners|All'); +export const I18N_INSTANCE_TYPE = s__('Runners|Instance'); +export const I18N_GROUP_TYPE = s__('Runners|Group'); +export const I18N_PROJECT_TYPE = s__('Runners|Project'); export const I18N_INSTANCE_RUNNER_DESCRIPTION = s__('Runners|Available to all projects'); export const I18N_GROUP_RUNNER_DESCRIPTION = s__( 'Runners|Available to all projects and subgroups in the group', @@ -28,9 +35,21 @@ export const I18N_STALE_RUNNER_DESCRIPTION = s__( 'Runners|No contact from this runner in over 3 months', ); +// Active flag +export const I18N_PAUSE = __('Pause'); +export const I18N_RESUME = __('Resume'); + export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects'); export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs'); +// Runner details + +export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})'); +export const I18N_NONE = __('None'); +export const I18N_NO_JOBS_FOUND = s__('Runner|This runner has not run any jobs.'); + +// Styles + export const RUNNER_TAG_BADGE_VARIANT = 'neutral'; export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100'; diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql index f7bcd683718..986dd16b992 100644 --- a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql @@ -28,10 +28,12 @@ query getGroupRunners( edges { webUrl node { + __typename ...RunnerNode } } pageInfo { + __typename ...PageInfo } } diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/get_runner.query.graphql index 59c55eae060..f6ce8281c64 100644 --- a/app/assets/javascripts/runner/graphql/get_runner.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_runner.query.graphql @@ -4,6 +4,7 @@ query getRunner($id: CiRunnerID!) { # We have an id in deeply nested fragment # eslint-disable-next-line @graphql-eslint/require-id-when-available runner(id: $id) { + __typename ...RunnerDetails } } diff --git a/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql b/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql new file mode 100644 index 00000000000..2b1decd3ddd --- /dev/null +++ b/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql @@ -0,0 +1,36 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, $after: String) { + runner(id: $id) { + id + projectCount + jobs(before: $before, after: $after, first: $first, last: $last) { + nodes { + id + detailedStatus { + # fields for `<ci-badge>` + id + detailsPath + group + icon + text + } + pipeline { + id + project { + id + name + webUrl + } + } + shortSha + commitPath + tags + finishedAt + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql new file mode 100644 index 00000000000..f97237b8267 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql @@ -0,0 +1,26 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getRunnerProjects( + $id: CiRunnerID! + $first: Int + $last: Int + $before: String + $after: String +) { + runner(id: $id) { + id + projectCount + projects(first: $first, last: $last, before: $before, after: $after) { + nodes { + id + avatarUrl + name + nameWithNamespace + webUrl + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_runners.query.graphql index 05df399fa6a..ed03a8c34ae 100644 --- a/app/assets/javascripts/runner/graphql/get_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_runners.query.graphql @@ -29,6 +29,7 @@ query getRunners( editAdminUrl } pageInfo { + __typename ...PageInfo } } diff --git a/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql deleted file mode 100644 index 547cc43907c..00000000000 --- a/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql +++ /dev/null @@ -1,14 +0,0 @@ -#import "~/runner/graphql/runner_node.fragment.graphql" - -# Mutation for updates within the runners list via action -# buttons (play, pause, ...), loads attributes shown in the -# runner list. - -mutation runnerActionsUpdate($input: RunnerUpdateInput!) { - runnerUpdate(input: $input) { - runner { - ...RunnerNode - } - errors - } -} diff --git a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql index 8e968343b9b..74760bbaa07 100644 --- a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql @@ -8,7 +8,27 @@ fragment RunnerDetailsShared on CiRunner { ipAddress description maximumTimeout + jobCount tagList createdAt status(legacyMode: null) + contactedAt + version + editAdminUrl + userPermissions { + updateRunner + deleteRunner + } + groups { + # Only a single group can be loaded here, while projects + # are loaded separately using the query with pagination + # parameters `get_runner_projects.query.graphql`. + nodes { + id + avatarUrl + name + fullName + webUrl + } + } } diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql index 4a771d779dc..fbdef817f2f 100644 --- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql @@ -1,4 +1,5 @@ fragment RunnerNode on CiRunner { + __typename id description runnerType diff --git a/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql new file mode 100644 index 00000000000..9b15570dbc0 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql @@ -0,0 +1,12 @@ +# Mutation executed for the pause/resume button in the +# runner list and details views. + +mutation runnerToggleActive($input: RunnerUpdateInput!) { + runnerUpdate(input: $input) { + runner { + id + active + } + errors + } +} diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index 3a7b58e3dc9..c4ee0ad4dfb 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -1,9 +1,9 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlBadge, GlLink } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; -import { formatNumber, sprintf, s__ } from '~/locale'; +import { formatNumber } from '~/locale'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; @@ -18,7 +18,7 @@ import { I18N_FETCH_ERROR, GROUP_FILTERED_SEARCH_NAMESPACE, GROUP_TYPE, - GROUP_RUNNER_COUNT_LIMIT, + PROJECT_TYPE, STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE, @@ -46,6 +46,7 @@ const runnersCountSmartQuery = { export default { name: 'GroupRunnersApp', components: { + GlBadge, GlLink, RegistrationDropdown, RunnerFilteredSearchBar, @@ -131,6 +132,33 @@ export default { }; }, }, + allRunnersCount: { + ...runnersCountSmartQuery, + variables() { + return { + ...this.countVariables, + type: null, + }; + }, + }, + groupRunnersCount: { + ...runnersCountSmartQuery, + variables() { + return { + ...this.countVariables, + type: GROUP_TYPE, + }; + }, + }, + projectRunnersCount: { + ...runnersCountSmartQuery, + variables() { + return { + ...this.countVariables, + type: PROJECT_TYPE, + }; + }, + }, }, computed: { variables() { @@ -139,23 +167,17 @@ export default { groupFullPath: this.groupFullPath, }; }, + countVariables() { + // Exclude pagination variables, leave only filters variables + const { sort, before, last, after, first, ...countVariables } = this.variables; + return countVariables; + }, runnersLoading() { return this.$apollo.queries.runners.loading; }, noRunnersFound() { return !this.runnersLoading && !this.runners.items.length; }, - groupRunnersCount() { - if (this.groupRunnersLimitedCount > GROUP_RUNNER_COUNT_LIMIT) { - return `${formatNumber(GROUP_RUNNER_COUNT_LIMIT)}+`; - } - return formatNumber(this.groupRunnersLimitedCount); - }, - runnerCountMessage() { - return sprintf(s__('Runners|Runners in this group: %{groupRunnersCount}'), { - groupRunnersCount: this.groupRunnersCount, - }); - }, searchTokens() { return [statusTokenConfig]; }, @@ -179,10 +201,31 @@ export default { this.reportToSentry(error); }, methods: { + tabCount({ runnerType }) { + let count; + switch (runnerType) { + case null: + count = this.allRunnersCount; + break; + case GROUP_TYPE: + count = this.groupRunnersCount; + break; + case PROJECT_TYPE: + count = this.projectRunnersCount; + break; + default: + return null; + } + if (typeof count === 'number') { + return formatNumber(count); + } + return null; + }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, }, + TABS_RUNNER_TYPES: [GROUP_TYPE, PROJECT_TYPE], GROUP_TYPE, }; </script> @@ -198,9 +241,17 @@ export default { <div class="gl-display-flex gl-align-items-center"> <runner-type-tabs v-model="search" + :runner-types="$options.TABS_RUNNER_TYPES" content-class="gl-display-none" nav-class="gl-border-none!" - /> + > + <template #title="{ tab }"> + {{ tab.title }} + <gl-badge v-if="tabCount(tab)" class="gl-ml-1" size="sm"> + {{ tabCount(tab) }} + </gl-badge> + </template> + </runner-type-tabs> <registration-dropdown class="gl-ml-auto" diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js index c80a73948b8..fe141332be3 100644 --- a/app/assets/javascripts/runner/runner_search_utils.js +++ b/app/assets/javascripts/runner/runner_search_utils.js @@ -18,6 +18,7 @@ import { RUNNER_PAGE_SIZE, STATUS_NEVER_CONTACTED, } from './constants'; +import { getPaginationVariables } from './utils'; /** * The filters and sorting of the runners are built around @@ -184,30 +185,27 @@ export const fromSearchToVariables = ({ sort = null, pagination = {}, } = {}) => { - const variables = {}; + const filterVariables = {}; const queryObj = filterToQueryObject(processFilters(filters), { filteredSearchTermKey: PARAM_KEY_SEARCH, }); - [variables.status] = queryObj[PARAM_KEY_STATUS] || []; - variables.search = queryObj[PARAM_KEY_SEARCH]; - variables.tagList = queryObj[PARAM_KEY_TAG]; + [filterVariables.status] = queryObj[PARAM_KEY_STATUS] || []; + filterVariables.search = queryObj[PARAM_KEY_SEARCH]; + filterVariables.tagList = queryObj[PARAM_KEY_TAG]; if (runnerType) { - variables.type = runnerType; + filterVariables.type = runnerType; } if (sort) { - variables.sort = sort; + filterVariables.sort = sort; } - if (pagination.before) { - variables.before = pagination.before; - variables.last = RUNNER_PAGE_SIZE; - } else { - variables.after = pagination.after; - variables.first = RUNNER_PAGE_SIZE; - } + const paginationVariables = getPaginationVariables(pagination, RUNNER_PAGE_SIZE); - return variables; + return { + ...filterVariables, + ...paginationVariables, + }; }; diff --git a/app/assets/javascripts/runner/utils.js b/app/assets/javascripts/runner/utils.js new file mode 100644 index 00000000000..6e4c8c45e7b --- /dev/null +++ b/app/assets/javascripts/runner/utils.js @@ -0,0 +1,72 @@ +import { formatNumber } from '~/locale'; +import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; +import { RUNNER_JOB_COUNT_LIMIT } from './constants'; + +/** + * Formats a job count, limited to a max number + * + * @param {Number} jobCount + * @returns Formatted string + */ +export const formatJobCount = (jobCount) => { + if (typeof jobCount !== 'number') { + return ''; + } + if (jobCount > RUNNER_JOB_COUNT_LIMIT) { + return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`; + } + return formatNumber(jobCount); +}; + +/** + * Returns a GlTable fields with a given key and label + * + * @param {Object} options + * @returns Field object to add to GlTable fields + */ +export const tableField = ({ key, label = '', thClasses = [] }) => { + return { + key, + label, + thClass: [DEFAULT_TH_CLASSES, ...thClasses], + tdAttr: { + 'data-testid': `td-${key}`, + }, + }; +}; + +/** + * Returns variables for a GraphQL query that uses keyset + * pagination. + * + * https://docs.gitlab.com/ee/development/graphql_guide/pagination.html#keyset-pagination + * + * @param {Object} pagination - Contains before, after, page + * @param {Number} pageSize + * @returns Variables + */ +export const getPaginationVariables = (pagination, pageSize = 10) => { + const { before, after } = pagination; + + // first + after: Next page + // Get the first N items after item X + if (after) { + return { + after, + first: pageSize, + }; + } + + // last + before: Prev page + // Get the first N items before item X, when you click on Prev + if (before) { + return { + before, + last: pageSize, + }; + } + + // first page + // Get the first N items + return { first: pageSize }; +}; diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index d228f77f27d..c48c9067250 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -50,7 +50,7 @@ export default { TrainingProviderList, }, mixins: [glFeatureFlagsMixin()], - inject: ['projectPath'], + inject: ['projectFullPath'], props: { augmentedSecurityFeatures: { type: Array, @@ -107,14 +107,14 @@ export default { shouldShowAutoDevopsEnabledAlert() { return ( this.autoDevopsEnabled && - !this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectPath) + !this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectFullPath) ); }, }, methods: { dismissAutoDevopsEnabledAlert() { const dismissedProjects = new Set(this.autoDevopsEnabledAlertDismissedProjects); - dismissedProjects.add(this.projectPath); + dismissedProjects.add(this.projectFullPath); this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects); }, onError(message) { diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 034dba29196..81d222438e3 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -123,7 +123,7 @@ export const COVERAGE_FUZZING_CONFIG_HELP_PATH = helpPagePath( export const CORPUS_MANAGEMENT_NAME = __('Corpus Management'); export const CORPUS_MANAGEMENT_DESCRIPTION = s__( - 'SecurityConfiguration|Manage corpus files used as mutation sources in coverage fuzzing.', + 'SecurityConfiguration|Manage corpus files used as seed inputs with coverage-guided fuzzing.', ); export const CORPUS_MANAGEMENT_CONFIG_TEXT = s__('SecurityConfiguration|Manage corpus'); @@ -159,15 +159,6 @@ export const securityFeatures = [ helpPath: SAST_HELP_PATH, configurationHelpPath: SAST_CONFIG_HELP_PATH, type: REPORT_TYPE_SAST, - // This field is currently hardcoded because SAST is always available. - // It will eventually come from the Backend, the progress is tracked in - // https://gitlab.com/gitlab-org/gitlab/-/issues/331622 - available: true, - - // This field is currently hardcoded because SAST can always be enabled via MR - // It will eventually come from the Backend, the progress is tracked in - // https://gitlab.com/gitlab-org/gitlab/-/issues/331621 - canEnableByMergeRequest: true, }, { name: SAST_IAC_NAME, @@ -176,15 +167,6 @@ export const securityFeatures = [ helpPath: SAST_IAC_HELP_PATH, configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH, type: REPORT_TYPE_SAST_IAC, - - // This field is currently hardcoded because SAST IaC is always available. - // It will eventually come from the Backend, the progress is tracked in - // https://gitlab.com/gitlab-org/gitlab/-/issues/331622 - available: true, - - // This field will eventually come from the backend, the progress is - // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621 - canEnableByMergeRequest: true, }, { name: DAST_NAME, @@ -206,10 +188,6 @@ export const securityFeatures = [ helpPath: DEPENDENCY_SCANNING_HELP_PATH, configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH, type: REPORT_TYPE_DEPENDENCY_SCANNING, - - // This field will eventually come from the backend, the progress is - // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621 - canEnableByMergeRequest: true, }, { name: CONTAINER_SCANNING_NAME, @@ -231,16 +209,6 @@ export const securityFeatures = [ helpPath: SECRET_DETECTION_HELP_PATH, configurationHelpPath: SECRET_DETECTION_CONFIG_HELP_PATH, type: REPORT_TYPE_SECRET_DETECTION, - - // This field is currently hardcoded because Secret Detection is always - // available. It will eventually come from the Backend, the progress is - // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/333113 - available: true, - - // This field is currently hardcoded because SAST can always be enabled via MR - // It will eventually come from the Backend, the progress is tracked in - // https://gitlab.com/gitlab-org/gitlab/-/issues/331621 - canEnableByMergeRequest: true, }, { name: API_FUZZING_NAME, diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index 33d72b54f86..1c37d8008de 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -24,9 +24,6 @@ export default { enabled() { return this.available && this.feature.configured; }, - hasStatus() { - return !this.available || typeof this.feature.configured === 'boolean'; - }, shortName() { return this.feature.shortName ?? this.feature.name; }, @@ -93,19 +90,17 @@ export default { data-testid="feature-status" :data-qa-selector="`${feature.type}_status`" > - <template v-if="hasStatus"> - <template v-if="enabled"> - <gl-icon name="check-circle-filled" /> - <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span> - </template> + <template v-if="enabled"> + <gl-icon name="check-circle-filled" /> + <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span> + </template> - <template v-else-if="available"> - {{ $options.i18n.notEnabled }} - </template> + <template v-else-if="available"> + {{ $options.i18n.notEnabled }} + </template> - <template v-else> - {{ $options.i18n.availableWith }} - </template> + <template v-else> + {{ $options.i18n.availableWith }} </template> </div> </div> diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue index ca4596e16b3..539e2bff17c 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -1,6 +1,13 @@ <script> import { GlAlert, GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import Tracking from '~/tracking'; import { __ } from '~/locale'; +import { + TRACK_TOGGLE_TRAINING_PROVIDER_ACTION, + TRACK_TOGGLE_TRAINING_PROVIDER_LABEL, +} from '~/security_configuration/constants'; +import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; import securityTrainingProvidersQuery from '../graphql/security_training_providers.query.graphql'; import configureSecurityTrainingProvidersMutation from '../graphql/configure_security_training_providers.mutation.graphql'; @@ -21,10 +28,19 @@ export default { GlLink, GlSkeletonLoader, }, - inject: ['projectPath'], + mixins: [Tracking.mixin()], + inject: ['projectFullPath'], apollo: { securityTrainingProviders: { query: securityTrainingProvidersQuery, + variables() { + return { + fullPath: this.projectFullPath, + }; + }, + update({ project }) { + return project?.securityTrainingProviders; + }, error() { this.errorMessage = this.$options.i18n.providerQueryErrorMessage; }, @@ -33,8 +49,9 @@ export default { data() { return { errorMessage: '', - toggleLoading: false, + providerLoadingId: null, securityTrainingProviders: [], + hasTouchedConfiguration: false, }; }, computed: { @@ -42,33 +59,59 @@ export default { return this.$apollo.queries.securityTrainingProviders.loading; }, }, + created() { + const unwatchConfigChance = this.$watch('hasTouchedConfiguration', () => { + this.dismissFeaturePromotionCallout(); + unwatchConfigChance(); + }); + }, methods: { - toggleProvider(selectedProviderId) { - const toggledProviders = this.securityTrainingProviders.map((provider) => ({ - ...provider, - ...(provider.id === selectedProviderId && { isEnabled: !provider.isEnabled }), - })); + async dismissFeaturePromotionCallout() { + try { + const { + data: { + userCalloutCreate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: dismissUserCalloutMutation, + variables: { + input: { + featureName: 'security_training_feature_promotion', + }, + }, + }); - const enabledProviderIds = toggledProviders - .filter(({ isEnabled }) => isEnabled) - .map(({ id }) => id); + // handle errors reported from the backend + if (errors?.length > 0) { + throw new Error(errors[0]); + } + } catch (e) { + Sentry.captureException(e); + } + }, + toggleProvider(provider) { + const { isEnabled } = provider; + const toggledIsEnabled = !isEnabled; - this.storeEnabledProviders(toggledProviders, enabledProviderIds); + this.trackProviderToggle(provider.id, toggledIsEnabled); + this.storeProvider({ ...provider, isEnabled: toggledIsEnabled }); }, - async storeEnabledProviders(toggledProviders, enabledProviderIds) { - this.toggleLoading = true; + async storeProvider({ id, isEnabled, isPrimary }) { + this.providerLoadingId = id; try { const { data: { - configureSecurityTrainingProviders: { errors = [] }, + securityTrainingUpdate: { errors = [] }, }, } = await this.$apollo.mutate({ mutation: configureSecurityTrainingProvidersMutation, variables: { input: { - enabledProviders: enabledProviderIds, - fullPath: this.projectPath, + projectPath: this.projectFullPath, + providerId: id, + isEnabled, + isPrimary, }, }, }); @@ -77,12 +120,23 @@ export default { // throwing an error here means we can handle scenarios within the `catch` block below throw new Error(); } + + this.hasTouchedConfiguration = true; } catch { this.errorMessage = this.$options.i18n.configMutationErrorMessage; } finally { - this.toggleLoading = false; + this.providerLoadingId = null; } }, + trackProviderToggle(providerId, providerIsEnabled) { + this.track(TRACK_TOGGLE_TRAINING_PROVIDER_ACTION, { + label: TRACK_TOGGLE_TRAINING_PROVIDER_LABEL, + property: providerId, + extra: { + providerIsEnabled, + }, + }); + }, }, i18n, }; @@ -104,25 +158,21 @@ export default { </gl-skeleton-loader> </div> <ul v-else class="gl-list-style-none gl-m-0 gl-p-0"> - <li - v-for="{ id, isEnabled, name, description, url } in securityTrainingProviders" - :key="id" - class="gl-mb-6" - > + <li v-for="provider in securityTrainingProviders" :key="provider.id" class="gl-mb-6"> <gl-card> <div class="gl-display-flex"> <gl-toggle - :value="isEnabled" + :value="provider.isEnabled" :label="__('Training mode')" label-position="hidden" - :is-loading="toggleLoading" - @change="toggleProvider(id)" + :is-loading="providerLoadingId === provider.id" + @change="toggleProvider(provider)" /> <div class="gl-ml-5"> - <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ name }}</h3> + <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ provider.name }}</h3> <p> - {{ description }} - <gl-link :href="url" target="_blank">{{ __('Learn more.') }}</gl-link> + {{ provider.description }} + <gl-link :href="provider.url" target="_blank">{{ __('Learn more.') }}</gl-link> </p> </div> </div> diff --git a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue index 79e6b9d7a23..891d7bf2eb0 100644 --- a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue +++ b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue @@ -1,11 +1,16 @@ <script> import { GlBanner } from '@gitlab/ui'; import { s__ } from '~/locale'; +import Tracking from '~/tracking'; + +export const SECURITY_UPGRADE_BANNER = 'security_upgrade_banner'; +export const UPGRADE_OR_FREE_TRIAL = 'upgrade_or_free_trial'; export default { components: { GlBanner, }, + mixins: [Tracking.mixin({ property: SECURITY_UPGRADE_BANNER })], inject: ['upgradePath'], i18n: { title: s__('SecurityConfiguration|Secure your project'), @@ -22,6 +27,17 @@ export default { ], buttonText: s__('SecurityConfiguration|Upgrade or start a free trial'), }, + mounted() { + this.track('render', { label: SECURITY_UPGRADE_BANNER }); + }, + methods: { + bannerClosed() { + this.track('dismiss_banner', { label: SECURITY_UPGRADE_BANNER }); + }, + bannerButtonClicked() { + this.track('click_button', { label: UPGRADE_OR_FREE_TRIAL }); + }, + }, }; </script> @@ -31,6 +47,8 @@ export default { :button-text="$options.i18n.buttonText" :button-link="upgradePath" variant="introduction" + @close="bannerClosed" + @primary="bannerButtonClicked" v-on="$listeners" > <p>{{ $options.i18n.bodyStart }}</p> diff --git a/app/assets/javascripts/security_configuration/constants.js b/app/assets/javascripts/security_configuration/constants.js new file mode 100644 index 00000000000..dc76436e91d --- /dev/null +++ b/app/assets/javascripts/security_configuration/constants.js @@ -0,0 +1,2 @@ +export const TRACK_TOGGLE_TRAINING_PROVIDER_ACTION = 'toggle_security_training_provider'; +export const TRACK_TOGGLE_TRAINING_PROVIDER_LABEL = 'update_security_training_provider'; diff --git a/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql b/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql index 660e0fadafb..3528bfaf7b8 100644 --- a/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql +++ b/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql @@ -1,9 +1,10 @@ -mutation configureSecurityTrainingProviders($input: configureSecurityTrainingProvidersInput!) { - configureSecurityTrainingProviders(input: $input) @client { +mutation updateSecurityTraining($input: SecurityTrainingUpdateInput!) { + securityTrainingUpdate(input: $input) { errors - securityTrainingProviders { + training { id isEnabled + isPrimary } } } diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql index e0c5715ba8e..2baeda318f3 100644 --- a/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql +++ b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql @@ -1,9 +1,13 @@ -query Query { - securityTrainingProviders @client { - name +query getSecurityTrainingProviders($fullPath: ID!) { + project(fullPath: $fullPath) { id - description - isEnabled - url + securityTrainingProviders { + name + id + description + isPrimary + isEnabled + url + } } } diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index 24c0585e077..8416692dd27 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -5,7 +5,6 @@ import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils'; import SecurityConfigurationApp from './components/app.vue'; import { securityFeatures, complianceFeatures } from './components/constants'; import { augmentFeatures } from './utils'; -import tempResolvers from './resolver'; export const initSecurityConfiguration = (el) => { if (!el) { @@ -15,11 +14,11 @@ export const initSecurityConfiguration = (el) => { Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(tempResolvers), + defaultClient: createDefaultClient(), }); const { - projectPath, + projectFullPath, upgradePath, features, latestPipelinePath, @@ -38,7 +37,7 @@ export const initSecurityConfiguration = (el) => { el, apolloProvider, provide: { - projectPath, + projectFullPath, upgradePath, autoDevopsHelpPagePath, autoDevopsPath, diff --git a/app/assets/javascripts/security_configuration/resolver.js b/app/assets/javascripts/security_configuration/resolver.js deleted file mode 100644 index 93175d4a3d1..00000000000 --- a/app/assets/javascripts/security_configuration/resolver.js +++ /dev/null @@ -1,56 +0,0 @@ -import produce from 'immer'; -import { __ } from '~/locale'; -import securityTrainingProvidersQuery from './graphql/security_training_providers.query.graphql'; - -// Note: this is behind a feature flag and only a placeholder -// until the actual GraphQL fields have been added -// https://gitlab.com/gitlab-org/gi tlab/-/issues/346480 -export default { - Query: { - securityTrainingProviders() { - return [ - { - __typename: 'SecurityTrainingProvider', - id: 101, - name: __('Kontra'), - description: __('Interactive developer security education.'), - url: 'https://application.security/', - isEnabled: false, - }, - { - __typename: 'SecurityTrainingProvider', - id: 102, - name: __('SecureCodeWarrior'), - description: __('Security training with guide and learning pathways.'), - url: 'https://www.securecodewarrior.com/', - isEnabled: true, - }, - ]; - }, - }, - - Mutation: { - configureSecurityTrainingProviders: ( - _, - { input: { enabledProviders, primaryProvider } }, - { cache }, - ) => { - const sourceData = cache.readQuery({ - query: securityTrainingProvidersQuery, - }); - - const data = produce(sourceData.securityTrainingProviders, (draftData) => { - /* eslint-disable no-param-reassign */ - draftData.forEach((provider) => { - provider.isPrimary = provider.id === primaryProvider; - provider.isEnabled = - provider.id === primaryProvider || enabledProviders.includes(provider.id); - }); - }); - return { - __typename: 'configureSecurityTrainingProvidersPayload', - securityTrainingProviders: data, - }; - }, - }, -}; diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js index 47231497b8f..173560f8370 100644 --- a/app/assets/javascripts/security_configuration/utils.js +++ b/app/assets/javascripts/security_configuration/utils.js @@ -1,6 +1,19 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants'; +/** + * This function takes in 3 arrays of objects, securityFeatures, complianceFeatures and features. + * securityFeatures and complianceFeatures are static arrays living in the constants. + * features is dynamic and coming from the backend. + * This function builds a superset of those arrays. + * It looks for matching keys within the dynamic and the static arrays + * and will enrich the objects with the available static data. + * @param [{}] securityFeatures + * @param [{}] complianceFeatures + * @param [{}] features + * @returns {Object} Object with enriched features from constants divided into Security and Compliance Features + */ + export const augmentFeatures = (securityFeatures, complianceFeatures, features = []) => { const featuresByType = features.reduce((acc, feature) => { acc[feature.type] = convertObjectPropsToCamelCase(feature, { deep: true }); diff --git a/app/assets/javascripts/serverless/components/empty_state.vue b/app/assets/javascripts/serverless/components/empty_state.vue index 8a5ed9debb3..6d1cea519c4 100644 --- a/app/assets/javascripts/serverless/components/empty_state.vue +++ b/app/assets/javascripts/serverless/components/empty_state.vue @@ -1,6 +1,8 @@ <script> import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { mapState } from 'vuex'; +import { s__ } from '~/locale'; +import { DEPRECATION_POST_LINK } from '../constants'; export default { components: { @@ -8,6 +10,13 @@ export default { GlLink, GlSprintf, }, + i18n: { + title: s__('Serverless|Getting started with serverless'), + description: s__( + 'Serverless|Serverless was %{postLinkStart}deprecated%{postLinkEnd}. But if you opt to use it, you must install Knative in your Kubernetes cluster first. %{linkStart}Learn more.%{linkEnd}', + ), + }, + deprecationPostLink: DEPRECATION_POST_LINK, computed: { ...mapState(['emptyImagePath', 'helpPath']), }, @@ -15,18 +24,12 @@ export default { </script> <template> - <gl-empty-state - :svg-path="emptyImagePath" - :title="s__('Serverless|Getting started with serverless')" - > + <gl-empty-state :svg-path="emptyImagePath" :title="$options.i18n.title"> <template #description> - <gl-sprintf - :message=" - s__( - 'Serverless|In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. %{linkStart}More information%{linkEnd}', - ) - " - > + <gl-sprintf :message="$options.i18n.description"> + <template #postLink="{ content }"> + <gl-link :href="$options.deprecationPostLink" target="_blank">{{ content }}</gl-link> + </template> <template #link="{ content }"> <gl-link :href="helpPath">{{ content }}</gl-link> </template> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index b2d7aa75051..e9461aa3ead 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -1,8 +1,14 @@ <script> -import { GlLink, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { + GlLink, + GlAlert, + GlSprintf, + GlLoadingIcon, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import { sprintf, s__ } from '~/locale'; -import { CHECKING_INSTALLED } from '../constants'; +import { CHECKING_INSTALLED, DEPRECATION_POST_LINK } from '../constants'; import EmptyState from './empty_state.vue'; import EnvironmentRow from './environment_row.vue'; @@ -11,11 +17,14 @@ export default { EnvironmentRow, EmptyState, GlLink, + GlAlert, + GlSprintf, GlLoadingIcon, }, directives: { SafeHtml, }, + deprecationPostLink: DEPRECATION_POST_LINK, computed: { ...mapState(['installed', 'isLoading', 'hasFunctionData', 'helpPath', 'statusPath']), ...mapGetters(['getFunctions']), @@ -65,6 +74,17 @@ export default { <template> <section id="serverless-functions" class="flex-grow"> + <gl-alert class="gl-mt-6" variant="warning" :dismissible="false"> + <gl-sprintf + :message="s__('Serverless|Serverless was %{linkStart}deprecated%{linkEnd} in GitLab 14.3.')" + ><template #link="{ content }" + ><gl-link :href="$options.deprecationPostLink" target="_blank">{{ + content + }}</gl-link></template + ></gl-sprintf + > + </gl-alert> + <gl-loading-icon v-if="checkingInstalled" size="lg" class="gl-mt-3 gl-mb-3" /> <div v-else-if="isInstalled"> diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js index 2fa15e56ccb..42c9ee983b4 100644 --- a/app/assets/javascripts/serverless/constants.js +++ b/app/assets/javascripts/serverless/constants.js @@ -5,3 +5,6 @@ export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-ax export const CHECKING_INSTALLED = 'checking'; // The backend is still determining whether or not Knative is installed export const TIMEOUT = 'timeout'; + +export const DEPRECATION_POST_LINK = + 'https://about.gitlab.com/releases/2021/09/22/gitlab-14-3-released/#gitlab-serverless'; diff --git a/app/assets/javascripts/serverless/survey_banner.js b/app/assets/javascripts/serverless/survey_banner.js deleted file mode 100644 index 070e8f4c661..00000000000 --- a/app/assets/javascripts/serverless/survey_banner.js +++ /dev/null @@ -1,36 +0,0 @@ -import Vue from 'vue'; -import { setUrlParams } from '~/lib/utils/url_utility'; -import SurveyBanner from './survey_banner.vue'; - -let bannerInstance; -const SURVEY_URL_BASE = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_00PfofFfY9s8Shf'; - -export default function initServerlessSurveyBanner() { - const el = document.querySelector('.js-serverless-survey-banner'); - if (el && !bannerInstance) { - const { userName, userEmail } = el.dataset; - - // pre-populate survey fields - const surveyUrl = setUrlParams( - { - Q_PopulateResponse: JSON.stringify({ - QID1: userEmail, - QID2: userName, - QID16: '1', // selects "yes" to "do you currently use GitLab?" - }), - }, - SURVEY_URL_BASE, - ); - - bannerInstance = new Vue({ - el, - render(createElement) { - return createElement(SurveyBanner, { - props: { - surveyUrl, - }, - }); - }, - }); - } -} diff --git a/app/assets/javascripts/serverless/survey_banner.vue b/app/assets/javascripts/serverless/survey_banner.vue deleted file mode 100644 index c48c294c0f7..00000000000 --- a/app/assets/javascripts/serverless/survey_banner.vue +++ /dev/null @@ -1,52 +0,0 @@ -<script> -import { GlBanner } from '@gitlab/ui'; -import Cookies from 'js-cookie'; -import { parseBoolean } from '~/lib/utils/common_utils'; - -export default { - components: { - GlBanner, - }, - props: { - surveyUrl: { - type: String, - required: true, - }, - }, - data() { - return { - visible: true, - }; - }, - created() { - if (parseBoolean(Cookies.get('hide_serverless_survey'))) { - this.visible = false; - } - }, - methods: { - handleClose() { - Cookies.set('hide_serverless_survey', 'true', { expires: 365 * 10 }); - this.visible = false; - }, - }, -}; -</script> - -<template> - <gl-banner - v-if="visible" - class="mt-4" - :title="s__('Serverless|Help shape the future of Serverless at GitLab')" - :button-text="s__('Serverless|Sign up for First Look')" - :button-link="surveyUrl" - @close="handleClose" - > - <p> - {{ - s__( - 'Serverless|We are continually striving to improve our Serverless functionality. As a Knative user, we would love to hear how we can make this experience better for you. Sign up for GitLab First Look today and we will be in touch shortly.', - ) - }} - </p> - </gl-banner> -</template> diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js index 2c6da5669ef..fe5b21713a2 100644 --- a/app/assets/javascripts/settings_panels.js +++ b/app/assets/javascripts/settings_panels.js @@ -18,8 +18,6 @@ export function expandSection(sectionArg) { const $section = $(sectionArg); $section.find('.js-settings-toggle:not(.js-settings-toggle-trigger-only)').text(__('Collapse')); - // eslint-disable-next-line @gitlab/no-global-event-off - $section.find('.settings-content').off('scroll.expandSection').scrollTop(0); $section.addClass('expanded'); if (!$section.hasClass('no-animate')) { $section @@ -32,7 +30,6 @@ export function closeSection(sectionArg) { const $section = $(sectionArg); $section.find('.js-settings-toggle:not(.js-settings-toggle-trigger-only)').text(__('Expand')); - $section.find('.settings-content').on('scroll.expandSection', () => expandSection($section)); $section.removeClass('expanded'); if (!$section.hasClass('no-animate')) { $section @@ -55,18 +52,16 @@ export default function initSettingsPanels() { const $section = $(elm); $section.on('click.toggleSection', '.js-settings-toggle', () => toggleSection($section)); - if (!isExpanded($section)) { - $section.find('.settings-content').on('scroll.expandSection', () => { - $section.removeClass('no-animate'); + if (window.location.hash) { + const $target = $(window.location.hash); + if ( + $target.length && + !isExpanded($section) && + ($section.is($target) || $section.find($target).length) + ) { + $section.addClass('no-animate'); expandSection($section); - }); + } } }); - - if (window.location.hash) { - const $target = $(window.location.hash); - if ($target.length && $target.hasClass('settings')) { - expandSection($target); - } - } } diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 5b4dc20e9c8..18654b73ab3 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -96,6 +96,9 @@ export default { return data.workspace?.issuable; }, result({ data }) { + if (!data) { + return; + } const issuable = data.workspace?.issuable; if (issuable) { this.selected = cloneDeep(issuable.assignees.nodes); diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue index dc0f2b54a7b..f234c5ea3c9 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue @@ -66,6 +66,9 @@ export default { return data.workspace?.issuable?.confidential || false; }, result({ data }) { + if (!data) { + return; + } this.$emit('confidentialityUpdated', data.workspace?.issuable?.confidential); }, error() { diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index 404bcc3122a..be7a89c2869 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -86,6 +86,9 @@ export default { return data.workspace?.issuable || {}; }, result({ data }) { + if (!data) { + return; + } this.$emit(`${this.dateType}Updated`, data.workspace?.issuable?.[this.dateType]); }, error() { diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index da792b3a2aa..ec23e817127 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -10,6 +10,7 @@ import { GlIcon, GlTooltipDirective, } from '@gitlab/ui'; +import { kebabCase, snakeCase } from 'lodash'; import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { IssuableType } from '~/issues/constants'; @@ -221,6 +222,12 @@ export default { // MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311 return this.issuableAttribute === IssuableType.Epic; }, + formatIssuableAttribute() { + return { + kebab: kebabCase(this.issuableAttribute), + snake: snakeCase(this.issuableAttribute), + }; + }, }, methods: { updateAttribute(attributeId) { @@ -300,21 +307,28 @@ export default { <sidebar-editable-item ref="editable" :title="attributeTypeTitle" - :data-testid="`${issuableAttribute}-edit`" + :data-testid="`${formatIssuableAttribute.kebab}-edit`" :tracking="tracking" :loading="updating || loading" @open="handleOpen" @close="handleClose" > <template #collapsed> - <div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon"> - <gl-icon :size="16" :aria-label="attributeTypeTitle" :name="attributeTypeIcon" /> - <span class="collapse-truncated-title"> - {{ attributeTitle }} - </span> - </div> + <slot name="value-collapsed" :current-attribute="currentAttribute"> + <div + v-if="isClassicSidebar" + v-gl-tooltip.left.viewport + :title="attributeTypeTitle" + class="sidebar-collapsed-icon" + > + <gl-icon :aria-label="attributeTypeTitle" :name="attributeTypeIcon" /> + <span class="collapse-truncated-title"> + {{ attributeTitle }} + </span> + </div> + </slot> <div - :data-testid="`select-${issuableAttribute}`" + :data-testid="`select-${formatIssuableAttribute.kebab}`" :class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'" > <span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span> @@ -332,7 +346,7 @@ export default { v-gl-tooltip="tooltipText" class="gl-text-gray-900! gl-font-weight-bold" :href="attributeUrl" - :data-qa-selector="`${issuableAttribute}_link`" + :data-qa-selector="`${formatIssuableAttribute.snake}_link`" > {{ attributeTitle }} <span v-if="isAttributeOverdue(currentAttribute)">{{ $options.i18n.expired }}</span> @@ -354,7 +368,7 @@ export default { > <gl-search-box-by-type ref="search" v-model="searchTerm" /> <gl-dropdown-item - :data-testid="`no-${issuableAttribute}-item`" + :data-testid="`no-${formatIssuableAttribute.kebab}-item`" :is-check-item="true" :is-checked="isAttributeChecked($options.noAttributeId)" @click="updateAttribute($options.noAttributeId)" @@ -384,7 +398,7 @@ export default { :key="attrItem.id" :is-check-item="true" :is-checked="isAttributeChecked(attrItem.id)" - :data-testid="`${issuableAttribute}-items`" + :data-testid="`${formatIssuableAttribute.kebab}-items`" @click="updateAttribute(attrItem.id)" > {{ attrItem.title }} diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue index 701833c4e95..7a10a9f3a4c 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -61,6 +61,9 @@ export default { return data.workspace?.issuable?.subscribed || false; }, result({ data }) { + if (!data) { + return; + } this.emailsDisabled = this.parentIsGroup ? data.workspace?.emailsDisabled : data.workspace?.issuable?.emailsDisabled; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue index 7c157fe2775..bb90ef8e444 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue @@ -38,7 +38,10 @@ export default { </script> <template> - <div data-testid="helpPane" class="time-tracking-help-state"> + <div + data-testid="helpPane" + class="sidebar-help-state gl-bg-white gl-border-gray-100 gl-border-t-solid gl-border-b-solid gl-border-1" + > <div class="time-tracking-info"> <h4>{{ __('Track time with quick actions') }}</h4> <p>{{ __('Quick actions can be used in description and comment boxes.') }}</p> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 91c67a03dfb..d222a2af382 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlLink, GlModal, GlModalDirective, GlLoadingIcon } from '@gitlab/ui'; +import { GlIcon, GlLink, GlModal, GlButton, GlModalDirective, GlLoadingIcon } from '@gitlab/ui'; import { IssuableType } from '~/issues/constants'; import { s__, __ } from '~/locale'; import { timeTrackingQueries } from '~/sidebar/constants'; @@ -21,6 +21,7 @@ export default { GlIcon, GlLink, GlModal, + GlButton, GlLoadingIcon, TimeTrackingCollapsedState, TimeTrackingSpentOnlyPane, @@ -187,7 +188,11 @@ export default { </script> <template> - <div v-cloak class="time-tracker time-tracking-component-wrap" data-testid="time-tracker"> + <div + v-cloak + class="time-tracker time-tracking-component-wrap sidebar-help-wrap" + data-testid="time-tracker" + > <time-tracking-collapsed-state v-if="showCollapsed" :show-comparison-state="showComparisonState" @@ -198,25 +203,21 @@ export default { :time-spent-human-readable="humanTotalTimeSpent" :time-estimate-human-readable="humanTimeEstimate" /> - <div class="hide-collapsed gl-line-height-20 gl-text-gray-900"> + <div + class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center" + > {{ __('Time tracking') }} <gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" inline /> - <div - v-if="!showHelpState" - data-testid="helpButton" - class="help-button float-right" - @click="toggleHelpState(true)" + <gl-button + :data-testid="showHelpState ? 'closeHelpButton' : 'helpButton'" + category="tertiary" + size="small" + variant="link" + class="gl-ml-auto" + @click="toggleHelpState(!showHelpState)" > - <gl-icon name="question-o" /> - </div> - <div - v-else - data-testid="closeHelpButton" - class="close-help-button float-right" - @click="toggleHelpState(false)" - > - <gl-icon name="close" /> - </div> + <gl-icon :name="showHelpState ? 'close' : 'question-o'" class="gl-text-gray-900!" /> + </gl-button> </div> <div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed"> <div v-if="showEstimateOnlyState" data-testid="estimateOnlyPane"> diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue index a9c4203af22..eabba619af5 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue @@ -59,6 +59,10 @@ export default { return data.workspace?.issuable?.currentUserTodos.nodes[0]?.id; }, result({ data }) { + if (!data) { + return; + } + const currentUserTodos = data.workspace?.issuable?.currentUserTodos?.nodes ?? []; this.todoId = currentUserTodos[0]?.id; this.$emit('todoUpdated', currentUserTodos.length > 0); @@ -177,19 +181,14 @@ export default { /> <gl-button v-if="isClassicSidebar" + v-gl-tooltip.left.viewport + :title="tootltipTitle" category="tertiary" type="reset" class="sidebar-collapsed-icon sidebar-collapsed-container gl-rounded-0! gl-shadow-none!" @click.stop.prevent="toggleTodo" > - <gl-icon - v-gl-tooltip.left.viewport - :title="tootltipTitle" - :size="16" - :class="{ 'todo-undone': hasTodo }" - :name="collapsedButtonIcon" - :aria-label="collapsedButtonIcon" - /> + <gl-icon :class="{ 'todo-undone': hasTodo }" :name="collapsedButtonIcon" /> </gl-button> </div> </template> diff --git a/app/assets/javascripts/sidebar/fragmentTypes.json b/app/assets/javascripts/sidebar/fragmentTypes.json deleted file mode 100644 index a1c68bba454..00000000000 --- a/app/assets/javascripts/sidebar/fragmentTypes.json +++ /dev/null @@ -1 +0,0 @@ -{"__schema":{"types":[{"kind":"UNION","name":"Issuable","possibleTypes":[{"name":"Issue"},{"name":"MergeRequest"}]}, {"kind":"INTERFACE","name":"User","possibleTypes":[{"name":"UserCore"}]}]}} diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js index 5b2ce3fe446..fc757922f09 100644 --- a/app/assets/javascripts/sidebar/graphql.js +++ b/app/assets/javascripts/sidebar/graphql.js @@ -1,15 +1,11 @@ -import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import produce from 'immer'; import VueApollo from 'vue-apollo'; import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql'; +import { resolvers as workItemResolvers } from '~/work_items/graphql/resolvers'; import createDefaultClient from '~/lib/graphql'; -import introspectionQueryResultData from './fragmentTypes.json'; - -const fragmentMatcher = new IntrospectionFragmentMatcher({ - introspectionQueryResultData, -}); const resolvers = { + ...workItemResolvers, Mutation: { updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => { const sourceData = cache.readQuery({ query: getIssueStateQuery }); @@ -18,14 +14,11 @@ const resolvers = { }); cache.writeQuery({ query: getIssueStateQuery, data }); }, + ...workItemResolvers.Mutation, }, }; -export const defaultClient = createDefaultClient(resolvers, { - cacheConfig: { - fragmentMatcher, - }, -}); +export const defaultClient = createDefaultClient(resolvers); export const apolloProvider = new VueApollo({ defaultClient, diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js index 1947c4801db..2aacce2fb00 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -21,6 +21,7 @@ export default class SidebarMilestone { // eslint-disable-next-line no-new new Vue({ el, + name: 'SidebarMilestoneRoot', components: { timeTracker, }, diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 6363422259e..c29784aa328 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -57,6 +57,7 @@ function mountSidebarToDoWidget() { return new Vue({ el, + name: 'SidebarTodoRoot', apolloProvider, components: { SidebarTodoWidget, @@ -103,6 +104,7 @@ function mountAssigneesComponentDeprecated(mediator) { // eslint-disable-next-line no-new new Vue({ el, + name: 'SidebarAssigneesRoot', apolloProvider, components: { SidebarAssignees, @@ -135,6 +137,7 @@ function mountAssigneesComponent() { // eslint-disable-next-line no-new new Vue({ el, + name: 'SidebarAssigneesRoot', apolloProvider, components: { SidebarAssigneesWidget, @@ -185,6 +188,7 @@ function mountReviewersComponent(mediator) { // eslint-disable-next-line no-new new Vue({ el, + name: 'SidebarReviewersRoot', apolloProvider, components: { SidebarReviewers, @@ -218,6 +222,7 @@ function mountCrmContactsComponent() { // eslint-disable-next-line no-new new Vue({ el, + name: 'SidebarCrmContactsRoot', apolloProvider, components: { CrmContacts, @@ -242,6 +247,7 @@ function mountMilestoneSelect() { return new Vue({ el, + name: 'SidebarMilestoneRoot', apolloProvider, components: { SidebarDropdownWidget, @@ -274,6 +280,7 @@ export function mountSidebarLabels() { return new Vue({ el, + name: 'SidebarLabelsRoot', apolloProvider, components: { @@ -328,6 +335,7 @@ function mountConfidentialComponent() { // eslint-disable-next-line no-new new Vue({ el, + name: 'SidebarConfidentialRoot', apolloProvider, components: { SidebarConfidentialityWidget, @@ -362,6 +370,7 @@ function mountDueDateComponent() { // eslint-disable-next-line no-new new Vue({ el, + name: 'SidebarDueDateRoot', apolloProvider, components: { SidebarDueDateWidget, @@ -392,6 +401,7 @@ function mountReferenceComponent() { // eslint-disable-next-line no-new new Vue({ el, + name: 'SidebarReferenceRoot', apolloProvider, components: { SidebarReferenceWidget, @@ -428,6 +438,7 @@ function mountLockComponent(store) { // eslint-disable-next-line no-new new Vue({ el, + name: 'SidebarLockRoot', store, provide: { fullPath, @@ -451,6 +462,7 @@ function mountParticipantsComponent() { // eslint-disable-next-line no-new new Vue({ el, + name: 'SidebarParticipantsRoot', apolloProvider, components: { SidebarParticipantsWidget, @@ -479,6 +491,7 @@ function mountSubscriptionsComponent() { // eslint-disable-next-line no-new new Vue({ el, + name: 'SidebarSubscriptionsRoot', apolloProvider, components: { SidebarSubscriptionsWidget, @@ -509,6 +522,7 @@ function mountTimeTrackingComponent() { // eslint-disable-next-line no-new new Vue({ el, + name: 'SidebarTimeTrackingRoot', apolloProvider, provide: { issuableType }, render: (createElement) => @@ -534,6 +548,7 @@ function mountSeverityComponent() { return new Vue({ el: severityContainerEl, + name: 'SidebarSeverityRoot', apolloProvider, components: { SidebarSeverity, @@ -562,6 +577,7 @@ function mountCopyEmailComponent() { // eslint-disable-next-line no-new new Vue({ el, + name: 'SidebarCopyEmailRoot', render: (createElement) => createElement(CopyEmailToClipboard, { props: { issueEmailAddress: createNoteEmail } }), }); diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 25468d4a697..4664bb56958 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -1,4 +1,4 @@ -import Store from 'ee_else_ce/sidebar/stores/sidebar_store'; +import Store from '~/sidebar/stores/sidebar_store'; import createFlash from '~/flash'; import { __, sprintf } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; diff --git a/app/assets/javascripts/tabs/constants.js b/app/assets/javascripts/tabs/constants.js index 3b84d7394d4..90c9a89d652 100644 --- a/app/assets/javascripts/tabs/constants.js +++ b/app/assets/javascripts/tabs/constants.js @@ -1,8 +1,4 @@ -export const ACTIVE_TAB_CLASSES = Object.freeze([ - 'active', - 'gl-tab-nav-item-active', - 'gl-tab-nav-item-active-indigo', -]); +export const ACTIVE_TAB_CLASSES = Object.freeze(['active', 'gl-tab-nav-item-active']); export const ACTIVE_PANEL_CLASS = 'active'; diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue index d066834540f..efc2991f40f 100644 --- a/app/assets/javascripts/terraform/components/states_table.vue +++ b/app/assets/javascripts/terraform/components/states_table.vue @@ -1,14 +1,5 @@ <script> -import { - GlAlert, - GlBadge, - GlIcon, - GlLink, - GlLoadingIcon, - GlSprintf, - GlTable, - GlTooltip, -} from '@gitlab/ui'; +import { GlAlert, GlBadge, GlLink, GlLoadingIcon, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { s__ } from '~/locale'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; @@ -21,7 +12,6 @@ export default { CiBadge, GlAlert, GlBadge, - GlIcon, GlLink, GlLoadingIcon, GlSprintf, @@ -128,7 +118,7 @@ export default { > <template #cell(name)="{ item }"> <div - class="gl-display-flex align-items-center gl-justify-content-end gl-justify-content-md-start" + class="gl-display-flex align-items-center gl-justify-content-end gl-md-justify-content-start" data-testid="terraform-states-table-name" > <p class="gl-font-weight-bold gl-m-0 gl-text-gray-900"> @@ -156,8 +146,7 @@ export default { :id="`terraformLockedBadgeContainer${item.name}`" class="gl-mx-3" > - <gl-badge :id="`terraformLockedBadge${item.name}`"> - <gl-icon name="lock" /> + <gl-badge :id="`terraformLockedBadge${item.name}`" icon="lock"> {{ $options.i18n.locked }} </gl-badge> diff --git a/app/assets/javascripts/terraform/index.js b/app/assets/javascripts/terraform/index.js index 1b8cab0d51e..34261f3c4db 100644 --- a/app/assets/javascripts/terraform/index.js +++ b/app/assets/javascripts/terraform/index.js @@ -1,5 +1,5 @@ +import { defaultDataIdFromObject } from '@apollo/client/core'; import { GlToast } from '@gitlab/ui'; -import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; diff --git a/app/assets/javascripts/toggles/index.js b/app/assets/javascripts/toggles/index.js new file mode 100644 index 00000000000..046b9fc7dcd --- /dev/null +++ b/app/assets/javascripts/toggles/index.js @@ -0,0 +1,65 @@ +import { kebabCase } from 'lodash'; +import Vue from 'vue'; +import { GlToggle } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; + +export const initToggle = (el) => { + if (!el) { + return false; + } + + const { + name, + isChecked, + disabled, + isLoading, + label, + help, + labelPosition, + ...dataset + } = el.dataset; + + return new Vue({ + el, + props: { + disabled: { + type: Boolean, + required: false, + default: parseBoolean(disabled), + }, + isLoading: { + type: Boolean, + required: false, + default: parseBoolean(isLoading), + }, + }, + data() { + return { + value: parseBoolean(isChecked), + }; + }, + render(h) { + return h(GlToggle, { + props: { + name, + value: this.value, + disabled: this.disabled, + isLoading: this.isLoading, + label, + help, + labelPosition, + }, + class: el.className, + attrs: Object.fromEntries( + Object.entries(dataset).map(([key, value]) => [`data-${kebabCase(key)}`, value]), + ), + on: { + change: (newValue) => { + this.value = newValue; + this.$emit('change', newValue); + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js index 49a43b120e0..4639671984a 100644 --- a/app/assets/javascripts/tooltips/index.js +++ b/app/assets/javascripts/tooltips/index.js @@ -21,6 +21,7 @@ const tooltipsApp = () => { document.body.appendChild(container); app = new Vue({ + name: 'TooltipsRoot', render(h) { return h(Tooltips, { props: { diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js index 44e54c85f3c..ee23f8c5a0c 100644 --- a/app/assets/javascripts/user_callout.js +++ b/app/assets/javascripts/user_callout.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import Cookies from 'js-cookie'; +import { getCookie, setCookie } from '~/lib/utils/common_utils'; export default class UserCallout { constructor(options = {}) { @@ -9,7 +9,7 @@ export default class UserCallout { this.userCalloutBody = $(`.${className}`); this.cookieName = this.userCalloutBody.data('uid'); - this.isCalloutDismissed = Cookies.get(this.cookieName); + this.isCalloutDismissed = getCookie(this.cookieName); this.init(); } @@ -30,7 +30,7 @@ export default class UserCallout { cookieOptions.path = this.userCalloutBody.data('projectPath'); } - Cookies.set(this.cookieName, 'true', cookieOptions); + setCookie(this.cookieName, 'true', cookieOptions); if ($currentTarget.hasClass('close') || $currentTarget.hasClass('js-close')) { this.userCalloutBody.remove(); diff --git a/app/assets/javascripts/vue_alerts.js b/app/assets/javascripts/vue_alerts.js index b44f787cf30..f3bf121c0f8 100644 --- a/app/assets/javascripts/vue_alerts.js +++ b/app/assets/javascripts/vue_alerts.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import Cookies from 'js-cookie'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { setCookie, parseBoolean } from '~/lib/utils/common_utils'; + import DismissibleAlert from '~/vue_shared/components/dismissible_alert.vue'; const getCookieExpirationPeriod = (expirationPeriod) => { @@ -33,7 +33,7 @@ const mountVueAlert = (el) => { if (!dismissCookieName) { return; } - Cookies.set(dismissCookieName, true, { + setCookie(dismissCookieName, true, { expires: getCookieExpirationPeriod(dismissCookieExpire), }); }, 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 5ef7c2f72e0..7ba387c79b1 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 @@ -1,5 +1,6 @@ <script> import createFlash from '~/flash'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -79,6 +80,7 @@ export default { [STOPPING]: { actionName: STOPPING, buttonText: s__('MrDeploymentActions|Stop environment'), + buttonVariant: 'danger', busyText: __('This environment is being deployed'), confirmMessage: __('Are you sure you want to stop this environment?'), errorMessage: __('Something went wrong while stopping this environment. Please try again.'), @@ -86,6 +88,7 @@ export default { [DEPLOYING]: { actionName: DEPLOYING, buttonText: s__('MrDeploymentActions|Deploy'), + buttonVariant: 'confirm', busyText: __('This environment is being deployed'), confirmMessage: __('Are you sure you want to deploy this environment?'), errorMessage: __('Something went wrong while deploying this environment. Please try again.'), @@ -93,14 +96,27 @@ export default { [REDEPLOYING]: { actionName: REDEPLOYING, buttonText: s__('MrDeploymentActions|Re-deploy'), + buttonVariant: 'confirm', busyText: __('This environment is being re-deployed'), confirmMessage: __('Are you sure you want to re-deploy this environment?'), errorMessage: __('Something went wrong while deploying this environment. Please try again.'), }, }, methods: { - executeAction(endpoint, { actionName, confirmMessage, errorMessage }) { - const isConfirmed = confirm(confirmMessage); //eslint-disable-line + async executeAction( + endpoint, + { + actionName, + buttonText: primaryBtnText, + buttonVariant: primaryBtnVariant, + confirmMessage, + errorMessage, + }, + ) { + const isConfirmed = await confirmAction(confirmMessage, { + primaryBtnVariant, + primaryBtnText, + }); if (isConfirmed) { this.actionInProgress = actionName; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 7322958e6df..a25b4ab54e5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -128,10 +128,12 @@ export default { api.trackRedisHllUserEvent(this.$options.expandEvent); } }), - toggleCollapsed() { - this.isCollapsed = !this.isCollapsed; + toggleCollapsed(e) { + if (!e?.target?.closest('.btn:not(.btn-icon),a')) { + this.isCollapsed = !this.isCollapsed; - this.triggerRedisTracking(); + this.triggerRedisTracking(); + } }, initExtensionPolling() { const poll = new Poll({ @@ -139,7 +141,7 @@ export default { fetchData: () => this.fetchCollapsedData(this.$props), }, method: 'fetchData', - successCallback: (data) => { + successCallback: ({ data }) => { if (Object.keys(data).length > 0) { poll.stop(); this.setCollapsedData(data); @@ -207,6 +209,19 @@ export default { this.showFade = true; } }, + onRowMouseDown() { + this.down = Number(new Date()); + }, + onRowMouseUp(e) { + const up = Number(new Date()); + + // To allow for text to be selected we check if the the user is clicking + // or selecting, if they are selecting the time difference should be + // more than 200ms + if (up - this.down < 200) { + this.toggleCollapsed(e); + } + }, generateText, }, EXTENSION_ICON_CLASS, @@ -215,7 +230,7 @@ export default { <template> <section class="media-section" data-testid="widget-extension"> - <div class="media gl-p-5"> + <div class="media gl-p-5 gl-cursor-pointer" @mousedown="onRowMouseDown" @mouseup="onRowMouseUp"> <status-icon :name="$options.label || $options.name" :is-loading="isLoadingSummary" @@ -253,7 +268,7 @@ export default { category="tertiary" data-testid="toggle-button" size="small" - @click="toggleCollapsed" + @click.self="toggleCollapsed" /> </div> </div> @@ -317,9 +332,13 @@ export default { <div v-if="data.link"> <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> </div> + <div v-if="data.supportingText"> + <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p> + </div> <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> {{ data.badge.text }} </gl-badge> + <actions :widget="$options.label || $options.name" :tertiary-buttons="data.actions" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue index cd5b7c3110d..8b410926c46 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue @@ -90,7 +90,7 @@ export default { </template> <div class="row"> <div - class="col-md-5 order-md-last col-12 gl-mt-5 gl-mt-md-n2! gl-pt-md-2 svg-content svg-225" + class="col-md-5 order-md-last col-12 gl-mt-5 gl-md-mt-n2! gl-md-pt-2 svg-content svg-225" > <img data-testid="pipeline-image" :src="pipelineSvgPath" /> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue new file mode 100644 index 00000000000..7279ad971be --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue @@ -0,0 +1,66 @@ +<script> +import { GlModal, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'MergeFailedPipelineConfirmationDialog', + i18n: { + primary: __('Merge unverified changes'), + cancel: __('Cancel'), + info: __( + 'The latest pipeline for this merge request did not succeed. The latest changes are unverified.', + ), + confirmation: __('Are you sure you want to attempt to merge?'), + title: __('Merge unverified changes?'), + }, + components: { + GlModal, + GlButton, + }, + props: { + visible: { + type: Boolean, + required: true, + }, + }, + methods: { + hide() { + this.$refs.modal.hide(); + }, + cancel() { + this.hide(); + this.$emit('cancel'); + }, + focusCancelButton() { + this.$refs.cancelButton.$el.focus(); + }, + mergeChanges() { + this.$emit('mergeWithFailedPipeline'); + this.hide(); + }, + }, +}; +</script> +<template> + <gl-modal + ref="modal" + size="sm" + modal-id="merge-train-failed-pipeline-confirmation-dialog" + :title="$options.i18n.title" + :visible="visible" + data-testid="merge-failed-pipeline-confirmation-dialog" + @shown="focusCancelButton" + @hide="$emit('cancel')" + > + <p>{{ $options.i18n.info }}</p> + <p>{{ $options.i18n.confirmation }}</p> + <template #modal-footer> + <gl-button ref="cancelButton" data-testid="merge-cancel-btn" @click="cancel">{{ + $options.i18n.cancel + }}</gl-button> + <gl-button variant="danger" data-testid="merge-unverified-changes" @click="mergeChanges"> + {{ $options.i18n.primary }} + </gl-button> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue index 247877a8235..e0c4679b983 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue @@ -1,7 +1,14 @@ <script> -import { MERGE_ACTIVE_STATUS_PHRASES } from '../../constants'; +import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; +import simplePoll from '~/lib/utils/simple_poll'; +import MergeRequest from '../../../merge_request'; +import eventHub from '../../event_hub'; +import { MERGE_ACTIVE_STATUS_PHRASES, STATE_MACHINE } from '../../constants'; import statusIcon from '../mr_widget_status_icon.vue'; +const { transitions } = STATE_MACHINE; +const { MERGE_FAILURE } = transitions; + export default { name: 'MRWidgetMerging', components: { @@ -12,6 +19,10 @@ export default { type: Object, required: true, }, + service: { + type: Object, + required: true, + }, }, data() { const statusCount = MERGE_ACTIVE_STATUS_PHRASES.length; @@ -20,6 +31,53 @@ export default { mergeStatus: MERGE_ACTIVE_STATUS_PHRASES[Math.floor(Math.random() * statusCount)], }; }, + mounted() { + this.initiateMergePolling(); + }, + methods: { + initiateMergePolling() { + simplePoll( + (continuePolling, stopPolling) => { + this.handleMergePolling(continuePolling, stopPolling); + }, + { timeout: 0 }, + ); + }, + handleMergePolling(continuePolling, stopPolling) { + this.service + .poll() + .then((res) => res.data) + .then((data) => { + if (data.state === 'merged') { + // If state is merged we should update the widget and stop the polling + eventHub.$emit('MRWidgetUpdateRequested'); + eventHub.$emit('FetchActionsContent'); + MergeRequest.hideCloseButton(); + MergeRequest.decreaseCounter(); + stopPolling(); + + refreshUserMergeRequestCounts(); + + // If user checked remove source branch and we didn't remove the branch yet + // we should start another polling for source branch remove process + if (this.removeSourceBranch && data.source_branch_exists) { + this.initiateRemoveSourceBranchPolling(); + } + } else if (data.merge_error) { + eventHub.$emit('FailedToMerge', data.merge_error); + this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); + stopPolling(); + } else { + // MR is not merged yet, continue polling until the state becomes 'merged' + continuePolling(); + } + }) + .catch(() => { + this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); + stopPolling(); + }); + }, + }, }; </script> <template> 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 5b03eda2eac..cadbd9c28a9 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,9 +1,14 @@ <script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; +import { GlIcon, GlTooltipDirective, GlSprintf } from '@gitlab/ui'; +import { sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import missingBranchQuery from '../../queries/states/missing_branch.query.graphql'; +import { + MR_WIDGET_MISSING_BRANCH_WHICH, + MR_WIDGET_MISSING_BRANCH_RESTORE, + MR_WIDGET_MISSING_BRANCH_MANUALCLI, +} from '../../i18n'; import statusIcon from '../mr_widget_status_icon.vue'; export default { @@ -13,6 +18,7 @@ export default { }, components: { GlIcon, + GlSprintf, statusIcon, }, mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], @@ -45,26 +51,20 @@ export default { return this.mr.sourceBranchRemoved; }, - missingBranchName() { + type() { return this.sourceBranchRemoved ? 'source' : 'target'; }, - missingBranchNameMessage() { - return sprintf( - s__('mrWidget| Please restore it or use a different %{missingBranchName} branch'), - { - missingBranchName: this.missingBranchName, - }, - ); + name() { + return this.type === 'source' ? this.mr.sourceBranch : this.mr.targetBranch; + }, + warning() { + return sprintf(MR_WIDGET_MISSING_BRANCH_WHICH, { type: this.type, name: this.name }); + }, + restore() { + return sprintf(MR_WIDGET_MISSING_BRANCH_RESTORE, { type: this.type }); }, message() { - return sprintf( - s__( - 'mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line', - ), - { - missingBranchName: this.missingBranchName, - }, - ); + return sprintf(MR_WIDGET_MISSING_BRANCH_MANUALCLI, { type: this.type }); }, }, }; @@ -79,9 +79,14 @@ export default { 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget, }" class="bold js-branch-text" + data-testid="widget-content" > - <span class="capitalize" data-testid="missingBranchName"> {{ missingBranchName }} </span> - {{ s__('mrWidget|branch does not exist.') }} {{ missingBranchNameMessage }} + <gl-sprintf :message="warning"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + {{ restore }} <gl-icon v-gl-tooltip :title="message" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue index d88dad2e086..d204befef58 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue @@ -20,7 +20,7 @@ export default { }, i18n: { failedMessage: s__( - `mrWidget|The pipeline for this merge request did not complete. Push a new commit to fix the failure, or check the %{linkStart}troubleshooting documentation%{linkEnd} to see other possible actions.`, + `mrWidget|Merge blocked: pipeline must succeed. Push a commit that fixes the failure, or %{linkStart}learn about other solutions.%{linkEnd}`, ), }, }; 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 06ce312bd4c..bc094501e89 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 @@ -14,7 +14,6 @@ import { import { isEmpty } from 'lodash'; import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; -import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import createFlash from '~/flash'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import simplePoll from '~/lib/utils/simple_poll'; @@ -22,11 +21,8 @@ import { __, s__ } from '~/locale'; import SmartInterval from '~/smart_interval'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { helpPagePath } from '~/helpers/help_page_helper'; -import MergeRequest from '../../../merge_request'; import { AUTO_MERGE_STRATEGIES, - DANGER, - CONFIRM, WARNING, MT_MERGE_STRATEGY, PIPELINE_FAILED_STATE, @@ -42,6 +38,7 @@ import CommitEdit from './commit_edit.vue'; import CommitMessageDropdown from './commit_message_dropdown.vue'; import CommitsHeader from './commits_header.vue'; import SquashBeforeMerge from './squash_before_merge.vue'; +import MergeFailedPipelineConfirmationDialog from './merge_failed_pipeline_confirmation_dialog.vue'; const PIPELINE_RUNNING_STATE = 'running'; const PIPELINE_PENDING_STATE = 'pending'; @@ -52,7 +49,7 @@ const MERGE_SUCCESS_STATUS = 'success'; const MERGE_HOOK_VALIDATION_ERROR_STATUS = 'hook_validation_error'; const { transitions } = STATE_MACHINE; -const { MERGE, MERGED, MERGE_FAILURE, AUTO_MERGE } = transitions; +const { MERGE, MERGE_FAILURE, AUTO_MERGE, MERGING } = transitions; export default { name: 'ReadyToMerge', @@ -106,6 +103,7 @@ export default { GlDropdownItem, GlFormCheckbox, GlSkeletonLoader, + MergeFailedPipelineConfirmationDialog, MergeTrainHelperIcon: () => import('ee_component/vue_merge_request_widget/components/merge_train_helper_icon.vue'), MergeImmediatelyConfirmationDialog: () => @@ -138,7 +136,8 @@ export default { squashBeforeMerge: this.mr.squashIsSelected, isSquashReadOnly: this.mr.squashIsReadonly, squashCommitMessage: this.mr.squashCommitMessage, - isPipelineFailedModalVisible: false, + isPipelineFailedModalVisibleMergeTrain: false, + isPipelineFailedModalVisibleNormalMerge: false, editCommitMessage: false, }; }, @@ -166,6 +165,9 @@ export default { return this.mr.isPipelineFailed; }, + showMergeFailedPipelineConfirmationDialog() { + return this.status === PIPELINE_FAILED_STATE && this.isPipelineFailed; + }, isMergeAllowed() { if (this.glFeatures.mergeRequestWidgetGraphql) { return this.state.mergeable; @@ -248,13 +250,6 @@ export default { return PIPELINE_SUCCESS_STATE; }, - mergeButtonVariant() { - if (this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) { - return DANGER; - } - - return CONFIRM; - }, iconClass() { if (this.shouldRenderMergeTrainHelperIcon && !this.mr.preventMerge) { return PIPELINE_RUNNING_STATE; @@ -279,6 +274,10 @@ export default { return this.autoMergeText; } + if (this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) { + return __('Merge...'); + } + return __('Merge'); }, hasPipelineMustSucceedConflict() { @@ -361,8 +360,13 @@ export default { return this.$apollo.queries.state.refetch(); }, handleMergeButtonClick(useAutoMerge, mergeImmediately = false, confirmationClicked = false) { - if (this.showFailedPipelineModal && !confirmationClicked) { - this.isPipelineFailedModalVisible = true; + if (this.showMergeFailedPipelineConfirmationDialog && !confirmationClicked) { + this.isPipelineFailedModalVisibleNormalMerge = true; + return; + } + + if (this.showFailedPipelineModalMergeTrain && !confirmationClicked) { + this.isPipelineFailedModalVisibleMergeTrain = true; return; } @@ -406,7 +410,7 @@ export default { eventHub.$emit('MRWidgetUpdateRequested'); this.mr.transitionStateMachine({ transition: AUTO_MERGE }); } else if (data.status === MERGE_SUCCESS_STATUS) { - this.initiateMergePolling(); + this.mr.transitionStateMachine({ transition: MERGING }); } else if (hasError) { eventHub.$emit('FailedToMerge', data.merge_error); this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); @@ -434,51 +438,8 @@ export default { onMergeImmediatelyConfirmation() { this.handleMergeButtonClick(false, true, true); }, - initiateMergePolling() { - simplePoll( - (continuePolling, stopPolling) => { - this.handleMergePolling(continuePolling, stopPolling); - }, - { timeout: 0 }, - ); - }, - handleMergePolling(continuePolling, stopPolling) { - this.service - .poll() - .then((res) => res.data) - .then((data) => { - if (data.state === 'merged') { - // If state is merged we should update the widget and stop the polling - eventHub.$emit('MRWidgetUpdateRequested'); - eventHub.$emit('FetchActionsContent'); - MergeRequest.hideCloseButton(); - MergeRequest.decreaseCounter(); - this.mr.transitionStateMachine({ transition: MERGED }); - stopPolling(); - - refreshUserMergeRequestCounts(); - - // If user checked remove source branch and we didn't remove the branch yet - // we should start another polling for source branch remove process - if (this.removeSourceBranch && data.source_branch_exists) { - this.initiateRemoveSourceBranchPolling(); - } - } else if (data.merge_error) { - eventHub.$emit('FailedToMerge', data.merge_error); - this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); - stopPolling(); - } else { - // MR is not merged yet, continue polling until the state becomes 'merged' - continuePolling(); - } - }) - .catch(() => { - createFlash({ - message: __('Something went wrong while merging this merge request. Please try again.'), - }); - this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); - stopPolling(); - }); + onMergeWithFailedPipelineConfirmation() { + this.handleMergeButtonClick(false, true, true); }, initiateRemoveSourceBranchPolling() { // We need to show source branch is being removed spinner in another component @@ -559,7 +520,7 @@ export default { category="primary" class="accept-merge-request" data-testid="merge-button" - :variant="mergeButtonVariant" + variant="confirm" :disabled="isMergeButtonDisabled" :loading="isMakingRequest" data-qa-selector="merge_button" @@ -570,7 +531,7 @@ export default { v-if="shouldShowMergeImmediatelyDropdown" v-gl-tooltip.hover.focus="__('Select merge moment')" :disabled="isMergeButtonDisabled" - :variant="mergeButtonVariant" + variant="confirm" data-qa-selector="merge_moment_dropdown" toggle-class="btn-icon js-merge-moment" > @@ -593,18 +554,22 @@ export default { /> </gl-dropdown> <merge-train-failed-pipeline-confirmation-dialog - :visible="isPipelineFailedModalVisible" + :visible="isPipelineFailedModalVisibleMergeTrain" @startMergeTrain="onStartMergeTrainConfirmation" - @cancel="isPipelineFailedModalVisible = false" + @cancel="isPipelineFailedModalVisibleMergeTrain = false" + /> + <merge-failed-pipeline-confirmation-dialog + :visible="isPipelineFailedModalVisibleNormalMerge" + @mergeWithFailedPipeline="onMergeWithFailedPipelineConfirmation" + @cancel="isPipelineFailedModalVisibleNormalMerge = false" /> </gl-button-group> + <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" /> <div v-if="shouldShowMergeControls" :class="{ 'gl-w-full gl-order-n1 gl-mb-5': glFeatures.restructuredMrWidget }" class="gl-display-flex gl-align-items-center gl-flex-wrap" > - <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" /> - <gl-form-checkbox v-if="canRemoveSourceBranch" id="remove-source-branch-input" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 8cf6383c26a..25ba4bf12af 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -43,8 +43,8 @@ export default { class="gl-ml-3" size="small" :icon="glFeatures.restructuredMrWidget ? undefined : 'comment-next'" - :variant="glFeatures.restructuredMrWidget && 'confirm'" - :category="glFeatures.restructuredMrWidget && 'secondary'" + :variant="glFeatures.restructuredMrWidget ? 'confirm' : 'default'" + :category="glFeatures.restructuredMrWidget ? 'secondary' : 'primary'" @click="jumpToFirstUnresolvedDiscussion" > {{ s__('mrWidget|Jump to first unresolved thread') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 32effb91043..d337a554663 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -68,6 +68,7 @@ const STATE_MACHINE = { states: { IDLE: 'IDLE', MERGING: 'MERGING', + MERGED: 'MERGED', AUTO_MERGE: 'AUTO_MERGE', }, transitions: { @@ -75,6 +76,7 @@ const STATE_MACHINE = { AUTO_MERGE: 'start-auto-merge', MERGE_FAILURE: 'merge-failed', MERGED: 'merge-done', + MERGING: 'merging', }, }; const { states, transitions } = STATE_MACHINE; @@ -86,11 +88,12 @@ STATE_MACHINE.definition = { on: { [transitions.MERGE]: states.MERGING, [transitions.AUTO_MERGE]: states.AUTO_MERGE, + [transitions.MERGING]: states.MERGING, }, }, [states.MERGING]: { on: { - [transitions.MERGED]: states.IDLE, + [transitions.MERGED]: states.MERGED, [transitions.MERGE_FAILURE]: states.IDLE, }, }, @@ -110,6 +113,7 @@ export const stateToTransitionMap = { }; export const stateToComponentMap = { [states.MERGING]: classStateMap[stateKey.merging], + [states.MERGED]: classStateMap[stateKey.merged], [states.AUTO_MERGE]: classStateMap[stateKey.autoMergeEnabled], }; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js new file mode 100644 index 00000000000..168f10bd148 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js @@ -0,0 +1,120 @@ +import { uniqueId } from 'lodash'; +import { __, n__, s__, sprintf } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import { EXTENSION_ICONS } from '../../constants'; + +export default { + name: 'WidgetAccessibility', + enablePolling: true, + i18n: { + loading: s__('Reports|Accessibility scanning results are being parsed'), + error: s__('Reports|Accessibility scanning failed loading results'), + }, + props: ['accessibilityReportPath'], + computed: { + statusIcon() { + return this.collapsedData.status === 'failed' + ? EXTENSION_ICONS.warning + : EXTENSION_ICONS.success; + }, + }, + methods: { + summary() { + const numOfResults = this.collapsedData?.summary?.errored || 0; + + const successText = s__( + 'Reports|Accessibility scanning detected no issues for the source branch only', + ); + const warningText = sprintf( + n__( + 'Reports|Accessibility scanning detected %{strong_start}%{number}%{strong_end} issue for the source branch only', + 'Reports|Accessibility scanning detected %{strong_start}%{number}%{strong_end} issues for the source branch only', + numOfResults, + ), + { + number: numOfResults, + }, + false, + ); + + return numOfResults === 0 ? successText : warningText; + }, + fetchCollapsedData() { + return axios.get(this.accessibilityReportPath); + }, + fetchFullData() { + return Promise.resolve(this.prepareReports()); + }, + parsedTECHSCode(code) { + /* + * In issue code looks like "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail" + * or "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent" + * + * The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation. + * Here we simply split the string on `.` and get the code in the 5th position + */ + return code?.split('.')[4]; + }, + formatLearnMoreUrl(code) { + const parsed = this.parsedTECHSCode(code); + // eslint-disable-next-line @gitlab/require-i18n-strings + return `https://www.w3.org/TR/WCAG20-TECHS/${parsed || 'Overview'}.html`; + }, + formatText(code) { + return sprintf( + s__( + 'AccessibilityReport|The accessibility scanning found an error of the following type: %{code}', + ), + { code }, + ); + }, + formatMessage(message) { + return sprintf(s__('AccessibilityReport|Message: %{message}'), { message }); + }, + prepareReports() { + const { new_errors, existing_errors, resolved_errors } = this.collapsedData; + + const newErrors = new_errors.map((error) => { + return { + header: __('New'), + id: uniqueId('new-error-'), + text: this.formatText(error.code), + icon: { name: EXTENSION_ICONS.failed }, + link: { + href: this.formatLearnMoreUrl(error.code), + text: __('Learn more'), + }, + supportingText: this.formatMessage(error.message), + }; + }); + + const existingErrors = existing_errors.map((error) => { + return { + id: uniqueId('existing-error-'), + text: this.formatText(error.code), + icon: { name: EXTENSION_ICONS.failed }, + link: { + href: this.formatLearnMoreUrl(error.code), + text: __('Learn more'), + }, + supportingText: this.formatMessage(error.message), + }; + }); + + const resolvedErrors = resolved_errors.map((error) => { + return { + id: uniqueId('resolved-error-'), + text: this.formatText(error.code), + icon: { name: EXTENSION_ICONS.success }, + link: { + href: this.formatLearnMoreUrl(error.code), + text: __('Learn more'), + }, + supportingText: this.formatMessage(error.message), + }; + }); + + return [...newErrors, ...existingErrors, ...resolvedErrors]; + }, + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index ba3336df2eb..4aeebf095c4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -25,9 +25,9 @@ export default { n__( 'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} change', 'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} changes', - changesFound, + count, ), - { changesFound }, + { changesFound: count }, ); }, // Status icon to be used next to the summary text diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js index a564acada02..8fcc4f818ec 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js @@ -73,26 +73,30 @@ export default { return `${title}${subtitle}`; }, fetchCollapsedData() { - return Promise.resolve(this.fetchPlans().then(this.prepareReports)); - }, - fetchFullData() { - const { valid, invalid } = this.collapsedData; - return Promise.resolve([...valid, ...invalid]); - }, - // Custom methods - fetchPlans() { return axios .get(this.terraformReportsPath) - .then(({ data }) => { - return Object.keys(data).map((key) => { - return data[key]; + .then((res) => { + const reports = Object.keys(res.data).map((key) => { + return res.data[key]; }); + + const formattedData = this.prepareReports(reports); + + return { + ...res, + data: formattedData, + }; }) .catch(() => { - const invalidData = { tf_report_error: 'api_error' }; - return [invalidData]; + const formattedData = this.prepareReports([{ tf_report_error: 'api_error' }]); + + return { data: formattedData }; }); }, + fetchFullData() { + const { valid, invalid } = this.collapsedData; + return Promise.resolve([...valid, ...invalid]); + }, createReportRow(report, iconName) { const addNum = Number(report.create); const changeNum = Number(report.update); diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js index c88e795e5f3..454a14faabb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/i18n.js +++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js @@ -1,4 +1,14 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; + +export const MR_WIDGET_MISSING_BRANCH_WHICH = s__( + 'mrWidget|The %{type} branch %{codeStart}%{name}%{codeEnd} does not exist.', +); +export const MR_WIDGET_MISSING_BRANCH_RESTORE = s__( + 'mrWidget|Please restore it or use a different %{type} branch.', +); +export const MR_WIDGET_MISSING_BRANCH_MANUALCLI = s__( + 'mrWidget|If the %{type} branch exists in your local repository, you can merge this merge request manually using the command line.', +); export const SQUASH_BEFORE_MERGE = { tooltipTitle: __('Required in this project.'), @@ -10,3 +20,8 @@ export const I18N_SHA_MISMATCH = { warningMessage: __('Merge blocked: new changes were just added.'), actionButtonLabel: __('Review changes'), }; + +export const MERGE_TRAIN_BUTTON_TEXT = { + failed: __('Start merge train...'), + passed: __('Start merge train'), +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index fa618756bb5..247a3711fc8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -48,7 +48,7 @@ export default { pipelineId() { return this.pipeline.id; }, - showFailedPipelineModal() { + showFailedPipelineModalMergeTrain() { return false; }, }, diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 83a07240403..11de58aa344 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -45,6 +45,7 @@ import eventHub from './event_hub'; import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables'; import getStateQuery from './queries/get_state.query.graphql'; import terraformExtension from './extensions/terraform'; +import accessibilityExtension from './extensions/accessibility'; export default { // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 @@ -205,7 +206,7 @@ export default { ); }, shouldShowAccessibilityReport() { - return this.mr.accessibilityReportPath; + return Boolean(this.mr?.accessibilityReportPath); }, formattedHumanAccess() { return (this.mr.humanAccess || '').toLowerCase(); @@ -240,6 +241,11 @@ export default { this.registerTerraformPlans(); } }, + shouldShowAccessibilityReport(newVal) { + if (newVal) { + this.registerAccessibilityExtension(); + } + }, }, mounted() { MRWidgetService.fetchInitialData() @@ -478,6 +484,11 @@ export default { registerExtension(terraformExtension); } }, + registerAccessibilityExtension() { + if (this.shouldShowAccessibilityReport && this.shouldShowExtension) { + registerExtension(accessibilityExtension); + } + }, }, }; </script> @@ -567,7 +578,7 @@ export default { :endpoint="mr.accessibilityReportPath" /> - <div class="mr-widget-section"> + <div class="mr-widget-section" data-qa-selector="mr_widget_content"> <component :is="componentName" :mr="mr" :service="service" /> <ready-to-merge v-if="isRestructuredMrWidgetEnabled && mr.commitsCount" diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql index 0b8396b4461..25c44beaf18 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql @@ -3,7 +3,6 @@ query getState($projectPath: ID!, $iid: String!) { id archived onlyAllowMergeIfPipelineSucceeds - mergeRequest(iid: $iid) { id autoMergeEnabled diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql index 2d79d35cf24..ad93a3a7371 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql @@ -4,6 +4,7 @@ query autoMergeEnabled($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { id mergeRequest(iid: $iid) { + id ...autoMergeEnabled } } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql index f713739f65a..556ecee254d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql @@ -2,6 +2,7 @@ query readyToMerge($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id ...ReadyToMerge } } diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js index 9f1da9ae173..d0155c18b9c 100644 --- a/app/assets/javascripts/vue_shared/alert_details/index.js +++ b/app/assets/javascripts/vue_shared/alert_details/index.js @@ -1,4 +1,4 @@ -import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import { defaultDataIdFromObject } from '@apollo/client/core'; import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -70,6 +70,7 @@ export default (selector) => { // eslint-disable-next-line no-new new Vue({ el: selector, + name: 'AlertDetailsRoot', components: { AlertDetails, }, diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index 82a28d4cb5f..b6010d4b70c 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -75,6 +75,9 @@ export default { return this.noteAuthorId === this.currentUserId; }, }, + mounted() { + this.virtualScrollerItem = this.$el.closest('.vue-recycle-scroller__item-view'); + }, methods: { getAwardClassBindings(awardList) { return { @@ -162,6 +165,10 @@ export default { }, setIsMenuOpen(menuOpen) { this.isMenuOpen = menuOpen; + + if (this.virtualScrollerItem) { + this.virtualScrollerItem.style.zIndex = this.isMenuOpen ? 1 : null; + } }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue index 2c74d56f617..3aaa7d915ea 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue @@ -1,6 +1,7 @@ <script> import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import LineHighlighter from '~/blob/line_highlighter'; import { HIGHLIGHT_CLASS_NAME } from './constants'; import ViewerMixin from './mixins'; @@ -20,13 +21,22 @@ export default { }; }, computed: { + refactorBlobViewerEnabled() { + return this.glFeatures.refactorBlobViewer; + }, + lineNumbers() { return this.content.split('\n').length; }, }, mounted() { - const { hash } = window.location; - if (hash) this.scrollToLine(hash, true); + if (this.refactorBlobViewerEnabled) { + // This line will be removed once we start using highlight.js on the frontend (https://gitlab.com/groups/gitlab-org/-/epics/7146) + new LineHighlighter(); // eslint-disable-line no-new + } else { + const { hash } = window.location; + if (hash) this.scrollToLine(hash, true); + } }, methods: { scrollToLine(hash, scroll = false) { @@ -51,7 +61,7 @@ export default { <template> <div> <div class="file-content code js-syntax-highlight" :class="$options.userColorScheme"> - <div v-if="!hideLineNumbers" class="line-numbers"> + <div v-if="!hideLineNumbers" class="line-numbers gl-pt-0!"> <a v-for="line in lineNumbers" :id="`L${line}`" @@ -67,7 +77,7 @@ export default { </div> <div class="blob-content"> <pre - class="code highlight" + class="code highlight gl-p-0! gl-display-flex" ><code v-safe-html="content" :data-blob-hash="blobHash"></code></pre> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue index 0575d7f6404..8b76af05ffe 100644 --- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue @@ -45,7 +45,8 @@ export default { :chart-data="chart.data" :area-chart-options="chartOptions" > - {{ dateRange }} + <p>{{ dateRange }}</p> + <slot name="metrics" :selected-chart="selectedChart"></slot> <template #tooltip-title> <slot name="tooltip-title"></slot> </template> diff --git a/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue new file mode 100644 index 00000000000..64e3b5d0bae --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue @@ -0,0 +1,68 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export const i18n = { + btnText: __('Fork project'), + title: __('Fork project?'), + message: __( + 'You can’t edit files directly in this project. Fork this project and submit a merge request with your changes.', + ), +}; + +export default { + name: 'ConfirmForkModal', + components: { + GlModal, + }, + model: { + prop: 'visible', + event: 'change', + }, + props: { + visible: { + type: Boolean, + required: false, + default: false, + }, + modalId: { + type: String, + required: true, + }, + forkPath: { + type: String, + required: true, + }, + }, + computed: { + btnActions() { + return { + cancel: { text: __('Cancel') }, + primary: { + text: this.$options.i18n.btnText, + attributes: { + href: this.forkPath, + variant: 'confirm', + 'data-qa-selector': 'fork_project_button', + 'data-method': 'post', + }, + }, + }; + }, + }, + i18n, +}; +</script> +<template> + <gl-modal + :visible="visible" + data-qa-selector="confirm_fork_modal" + :modal-id="modalId" + :title="$options.i18n.title" + :action-primary="btnActions.primary" + :action-cancel="btnActions.cancel" + @change="$emit('change', $event)" + > + <p>{{ $options.i18n.message }}</p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue index cb038a8c4e1..c411496fad1 100644 --- a/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue +++ b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue @@ -28,12 +28,37 @@ export default { required: false, default: false, }, + isOnImage: { + type: Boolean, + required: false, + default: false, + }, + isDraft: { + type: Boolean, + required: false, + default: false, + }, + size: { + type: String, + required: false, + default: 'md', + validator: (value) => ['sm', 'md'].includes(value), + }, + ariaLabel: { + type: String, + required: false, + default: null, + }, }, computed: { isNewNote() { return this.label === null; }, pinLabel() { + if (this.ariaLabel) { + return this.ariaLabel; + } + return this.isNewNote ? __('Comment form position') : sprintf(__("Comment '%{label}' position"), { label: this.label }); @@ -51,7 +76,10 @@ export default { 'js-image-badge design-note-pin': !isNewNote, resolved: isResolved, inactive: isInactive, + draft: isDraft, + 'on-image': isOnImage, 'gl-absolute': position, + small: size === 'sm', }" class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm" type="button" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 810d9f782b9..3d48c74b40b 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -23,9 +23,19 @@ export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title: export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') }; export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; +export const DEFAULT_MILESTONE_UPCOMING = { + value: FILTER_UPCOMING, + text: __('Upcoming'), + title: __('Upcoming'), +}; +export const DEFAULT_MILESTONE_STARTED = { + value: FILTER_STARTED, + text: __('Started'), + title: __('Started'), +}; export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([ - { value: FILTER_UPCOMING, text: __('Upcoming'), title: __('Upcoming') }, - { value: FILTER_STARTED, text: __('Started'), title: __('Started') }, + DEFAULT_MILESTONE_UPCOMING, + DEFAULT_MILESTONE_STARTED, ]); export const SortDirection = { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index bbc1888bc0b..157068b2c0f 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -163,19 +163,22 @@ export default { }, }, methods: { - handleInput: debounce(function debouncedSearch({ data }) { - this.searchKey = data; + handleInput: debounce(function debouncedSearch({ data, operator }) { + // Prevent fetching suggestions when data or operator is not present + if (data || operator) { + this.searchKey = data; - if (!this.suggestionsLoading && !this.activeTokenValue) { - let search = this.searchTerm ? this.searchTerm : data; + if (!this.suggestionsLoading && !this.activeTokenValue) { + let search = this.searchTerm ? this.searchTerm : data; - if (search.startsWith('"') && search.endsWith('"')) { - search = stripQuotes(search); - } else if (search.startsWith('"')) { - search = search.slice(1, search.length); - } + if (search.startsWith('"') && search.endsWith('"')) { + search = stripQuotes(search); + } else if (search.startsWith('"')) { + search = search.slice(1, search.length); + } - this.$emit('fetch-suggestions', search); + this.$emit('fetch-suggestions', search); + } } }, DEBOUNCE_DELAY), handleTokenValueSelected(selectedValue) { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 0d3394788fa..11c081ab4f8 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -57,7 +57,12 @@ export default { .fetchMilestones(searchTerm) .then((response) => { const data = Array.isArray(response) ? response : response.data; - this.milestones = data.slice().sort(sortMilestonesByDueDate); + + if (this.config.shouldSkipSort) { + this.milestones = data; + } else { + this.milestones = data.slice().sort(sortMilestonesByDueDate); + } }) .catch(() => { createFlash({ message: __('There was a problem fetching milestones.') }); diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue deleted file mode 100644 index 9ab91e567e6..00000000000 --- a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue +++ /dev/null @@ -1,106 +0,0 @@ -<script> -import Tribute from '@gitlab/tributejs'; -import { - GfmAutocompleteType, - tributeConfig, -} from 'ee_else_ce/vue_shared/components/gfm_autocomplete/utils'; -import * as Emoji from '~/emoji'; -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; -import SidebarMediator from '~/sidebar/sidebar_mediator'; - -export default { - errorMessage: __( - 'An error occurred while getting autocomplete data. Please refresh the page and try again.', - ), - props: { - autocompleteTypes: { - type: Array, - required: false, - default: () => Object.values(GfmAutocompleteType), - }, - dataSources: { - type: Object, - required: false, - default: () => gl.GfmAutoComplete?.dataSources || {}, - }, - }, - computed: { - config() { - return this.autocompleteTypes.map((type) => ({ - ...tributeConfig[type].config, - loadingItemTemplate: `<span class="gl-spinner gl-vertical-align-text-bottom gl-ml-3 gl-mr-2"></span>${__( - 'Loading', - )}`, - requireLeadingSpace: true, - values: this.getValues(type), - })); - }, - }, - mounted() { - this.cache = {}; - this.tribute = new Tribute({ collection: this.config }); - - const input = this.$slots.default?.[0]?.elm; - this.tribute.attach(input); - }, - beforeDestroy() { - const input = this.$slots.default?.[0]?.elm; - this.tribute.detach(input); - }, - methods: { - cacheAssignees() { - const isAssigneesLengthSame = - this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length; - - if (!this.assignees || !isAssigneesLengthSame) { - this.assignees = - SidebarMediator.singleton?.store?.assignees?.map((assignee) => assignee.username) || []; - } - }, - filterValues(type) { - // The assignees AJAX response can come after the user first invokes autocomplete - // so we need to check more than once if we need to update the assignee cache - this.cacheAssignees(); - - return tributeConfig[type].filterValues - ? tributeConfig[type].filterValues({ - assignees: this.assignees, - collection: this.cache[type], - fullText: this.$slots.default?.[0]?.elm?.value, - selectionStart: this.$slots.default?.[0]?.elm?.selectionStart, - }) - : this.cache[type]; - }, - getValues(type) { - return (inputText, processValues) => { - if (this.cache[type]) { - processValues(this.filterValues(type)); - } else if (type === GfmAutocompleteType.Emojis) { - Emoji.initEmojiMap() - .then(() => { - const emojis = Emoji.getValidEmojiNames(); - this.cache[type] = emojis; - processValues(emojis); - }) - .catch(() => createFlash({ message: this.$options.errorMessage })); - } else if (this.dataSources[type]) { - axios - .get(this.dataSources[type]) - .then((response) => { - this.cache[type] = response.data; - processValues(this.filterValues(type)); - }) - .catch(() => createFlash({ message: this.$options.errorMessage })); - } else { - processValues([]); - } - }; - }, - }, - render(createElement) { - return createElement('div', this.$slots.default); - }, -}; -</script> diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js deleted file mode 100644 index 44c3fc34ba6..00000000000 --- a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js +++ /dev/null @@ -1,195 +0,0 @@ -import { escape, last } from 'lodash'; -import * as Emoji from '~/emoji'; -import { spriteIcon } from '~/lib/utils/common_utils'; - -const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings - -// Number of users to show in the autocomplete menu to avoid doing a mass fetch of 100+ avatars -const memberLimit = 10; - -const nonWordOrInteger = /\W|^\d+$/; - -export const menuItemLimit = 100; - -export const GfmAutocompleteType = { - Emojis: 'emojis', - Issues: 'issues', - Labels: 'labels', - Members: 'members', - MergeRequests: 'mergeRequests', - Milestones: 'milestones', - QuickActions: 'commands', - Snippets: 'snippets', -}; - -function doesCurrentLineStartWith(searchString, fullText, selectionStart) { - const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length; - const currentLine = fullText.split('\n')[currentLineNumber - 1]; - return currentLine.startsWith(searchString); -} - -export const tributeConfig = { - [GfmAutocompleteType.Emojis]: { - config: { - trigger: ':', - lookup: (value) => value, - menuItemLimit, - menuItemTemplate: ({ original }) => `${original} ${Emoji.glEmojiTag(original)}`, - selectTemplate: ({ original }) => `:${original}:`, - }, - }, - - [GfmAutocompleteType.Issues]: { - config: { - trigger: '#', - lookup: (value) => `${value.iid}${value.title}`, - menuItemLimit, - menuItemTemplate: ({ original }) => - `<small>${original.reference || original.iid}</small> ${escape(original.title)}`, - selectTemplate: ({ original }) => original.reference || `#${original.iid}`, - }, - }, - - [GfmAutocompleteType.Labels]: { - config: { - trigger: '~', - lookup: 'title', - menuItemLimit, - menuItemTemplate: ({ original }) => ` - <span class="dropdown-label-box" style="background: ${escape(original.color)};"></span> - ${escape(original.title)}`, - selectTemplate: ({ original }) => - nonWordOrInteger.test(original.title) - ? `~"${escape(original.title)}"` - : `~${escape(original.title)}`, - }, - filterValues({ collection, fullText, selectionStart }) { - if (doesCurrentLineStartWith('/label', fullText, selectionStart)) { - return collection.filter((label) => !label.set); - } - - if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) { - return collection.filter((label) => label.set); - } - - return collection; - }, - }, - - [GfmAutocompleteType.Members]: { - config: { - trigger: '@', - fillAttr: 'username', - lookup: (value) => - value.type === groupType ? last(value.name.split(' / ')) : `${value.name}${value.username}`, - menuItemLimit: memberLimit, - menuItemTemplate: ({ original }) => { - const commonClasses = 'gl-avatar gl-avatar-s32 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 disabledMentionsIcon = original.mentionsDisabled - ? spriteIcon('notifications-off', 's16 gl-ml-3') - : ''; - - return ` - <div class="gl-display-flex gl-align-items-center"> - ${avatar} - <div class="gl-line-height-normal gl-ml-4"> - <div>${escape(displayName)}${count}</div> - <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div> - </div> - ${disabledMentionsIcon} - </div> - `; - }, - }, - filterValues({ assignees, collection, fullText, selectionStart }) { - if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) { - return collection.filter((member) => !assignees.includes(member.username)); - } - - if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) { - return collection.filter((member) => assignees.includes(member.username)); - } - - return collection; - }, - }, - - [GfmAutocompleteType.MergeRequests]: { - config: { - trigger: '!', - lookup: (value) => `${value.iid}${value.title}`, - menuItemLimit, - menuItemTemplate: ({ original }) => - `<small>${original.reference || original.iid}</small> ${escape(original.title)}`, - selectTemplate: ({ original }) => original.reference || `!${original.iid}`, - }, - }, - - [GfmAutocompleteType.Milestones]: { - config: { - trigger: '%', - lookup: 'title', - menuItemLimit, - menuItemTemplate: ({ original }) => escape(original.title), - selectTemplate: ({ original }) => `%"${escape(original.title)}"`, - }, - }, - - [GfmAutocompleteType.QuickActions]: { - config: { - trigger: '/', - fillAttr: 'name', - lookup: (value) => `${value.name}${value.aliases.join()}`, - menuItemLimit, - menuItemTemplate: ({ original }) => { - const aliases = original.aliases.length - ? `<small>(or /${original.aliases.join(', /')})</small>` - : ''; - - const params = original.params.length ? `<small>${original.params.join(' ')}</small>` : ''; - - let description = ''; - - if (original.warning) { - const confidentialIcon = - original.icon === 'confidential' ? spriteIcon('eye-slash', 's16 gl-mr-2') : ''; - description = `<small>${confidentialIcon}<em>${original.warning}</em></small>`; - } else if (original.description) { - description = `<small><em>${original.description}</em></small>`; - } - - return `<div>/${original.name} ${aliases} ${params}</div> - <div>${description}</div>`; - }, - }, - }, - - [GfmAutocompleteType.Snippets]: { - config: { - trigger: '$', - fillAttr: 'id', - lookup: (value) => `${value.id}${value.title}`, - menuItemLimit, - menuItemTemplate: ({ original }) => `<small>${original.id}</small> ${escape(original.title)}`, - }, - }, -}; diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue index f36b9107a6e..f3b871c91b6 100644 --- a/app/assets/javascripts/vue_shared/components/help_popover.vue +++ b/app/assets/javascripts/vue_shared/components/help_popover.vue @@ -33,6 +33,9 @@ export default { <template #default> <div v-safe-html="options.content"></div> </template> + <template v-for="slot in Object.keys($slots)" #[slot]> + <slot :name="slot"></slot> + </template> </gl-popover> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 5c86c928ce3..cbf38984e23 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -8,7 +8,6 @@ import GLForm from '~/gl_form'; import axios from '~/lib/utils/axios_utils'; import { stripHtml } from '~/lib/utils/text_utility'; import { __, sprintf } from '~/locale'; -import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MarkdownHeader from './header.vue'; @@ -20,7 +19,6 @@ function cleanUpLine(content) { export default { components: { - GfmAutocomplete, MarkdownHeader, MarkdownToolbar, GlIcon, @@ -212,15 +210,16 @@ export default { return new GLForm( $(this.$refs['gl-form']), { - emojis: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - epics: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, + emojis: this.enableAutocomplete, + members: this.enableAutocomplete, + issues: this.enableAutocomplete, + mergeRequests: this.enableAutocomplete, + epics: this.enableAutocomplete, + milestones: this.enableAutocomplete, + labels: this.enableAutocomplete, + snippets: this.enableAutocomplete, vulnerabilities: this.enableAutocomplete, + contacts: this.enableAutocomplete && this.glFeatures.contactsAutocomplete, }, true, ); @@ -311,10 +310,7 @@ export default { /> <div v-show="!previewMarkdown" class="md-write-holder"> <div class="zen-backdrop"> - <gfm-autocomplete v-if="glFeatures.tributeAutocomplete"> - <slot name="textarea"></slot> - </gfm-autocomplete> - <slot v-else name="textarea"></slot> + <slot name="textarea"></slot> <a class="zen-control zen-control-leave js-zen-leave gl-text-gray-500" href="#" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 3ed9de6c133..e2b6579a841 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -1,9 +1,9 @@ <script> -import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { GlPopover, GlButton, GlTooltipDirective, GlTabs, GlTab } from '@gitlab/ui'; import $ from 'jquery'; import { keysFor, BOLD_TEXT, ITALIC_TEXT, LINK_TEXT } from '~/behaviors/shortcuts/keybindings'; import { getSelectedFragment } from '~/lib/utils/common_utils'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm'; import ToolbarButton from './toolbar_button.vue'; @@ -12,6 +12,8 @@ export default { ToolbarButton, GlPopover, GlButton, + GlTabs, + GlTab, }, directives: { GlTooltip: GlTooltipDirective, @@ -144,136 +146,143 @@ export default { italic: keysFor(ITALIC_TEXT), link: keysFor(LINK_TEXT), }, + i18n: { + writeTabTitle: __('Write'), + previewTabTitle: __('Preview'), + }, }; </script> <template> <div class="md-header"> - <ul class="nav-links clearfix"> - <li :class="{ active: !previewMarkdown }" class="md-header-tab"> - <button class="js-write-link" type="button" @click="writeMarkdownTab($event)"> - {{ __('Write') }} - </button> - </li> - <li :class="{ active: previewMarkdown }" class="md-header-tab"> - <button - class="js-preview-link js-md-preview-button" - type="button" - @click="previewMarkdownTab($event)" - > - {{ __('Preview') }} - </button> - </li> - <li :class="{ active: !previewMarkdown }" class="md-header-toolbar"> - <toolbar-button - tag="**" - :button-title=" - sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey }) - " - :shortcuts="$options.shortcuts.bold" - icon="bold" - /> - <toolbar-button - tag="_" - :button-title=" - sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey }) - " - :shortcuts="$options.shortcuts.italic" - icon="italic" - /> - <toolbar-button - :prepend="true" - :tag="tag" - :button-title="__('Insert a quote')" - icon="quote" - @click="handleQuote" - /> - <template v-if="canSuggest"> + <gl-tabs content-class="gl-display-none"> + <gl-tab + title-link-class="gl-pt-3 gl-px-3 js-md-write-button" + :title="$options.i18n.writeTabTitle" + :active="!previewMarkdown" + data-testid="write-tab" + @click="writeMarkdownTab($event)" + /> + <gl-tab + title-link-class="gl-pt-3 gl-px-3 js-md-preview-button" + :title="$options.i18n.previewTabTitle" + :active="previewMarkdown" + data-testid="preview-tab" + @click="previewMarkdownTab($event)" + /> + + <template v-if="!previewMarkdown" #tabs-end> + <div class="md-header-toolbar gl-ml-auto gl-pb-3 gl-justify-content-center"> + <toolbar-button + tag="**" + :button-title=" + sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey }) + " + :shortcuts="$options.shortcuts.bold" + icon="bold" + /> + <toolbar-button + tag="_" + :button-title=" + sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey }) + " + :shortcuts="$options.shortcuts.italic" + icon="italic" + /> <toolbar-button - ref="suggestButton" - :tag="mdSuggestion" :prepend="true" - :button-title="__('Insert suggestion')" - :cursor-offset="4" - :tag-content="lineContent" - icon="doc-code" - data-qa-selector="suggestion_button" - class="js-suggestion-btn" - @click="handleSuggestDismissed" + :tag="tag" + :button-title="__('Insert a quote')" + icon="quote" + @click="handleQuote" /> - <gl-popover - v-if="suggestPopoverVisible" - :target="$refs.suggestButton.$el" - :css-classes="['diff-suggest-popover']" - placement="bottom" - :show="suggestPopoverVisible" - > - <strong>{{ __('New! Suggest changes directly') }}</strong> - <p class="mb-2"> - {{ - __( - 'Suggest code changes which can be immediately applied in one click. Try it out!', - ) - }} - </p> - <gl-button - variant="info" - category="primary" - size="small" + <template v-if="canSuggest"> + <toolbar-button + ref="suggestButton" + :tag="mdSuggestion" + :prepend="true" + :button-title="__('Insert suggestion')" + :cursor-offset="4" + :tag-content="lineContent" + icon="doc-code" + data-qa-selector="suggestion_button" + class="js-suggestion-btn" @click="handleSuggestDismissed" + /> + <gl-popover + v-if="suggestPopoverVisible" + :target="$refs.suggestButton.$el" + :css-classes="['diff-suggest-popover']" + placement="bottom" + :show="suggestPopoverVisible" > - {{ __('Got it') }} - </gl-button> - </gl-popover> - </template> - <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" /> - <toolbar-button - tag="[{text}](url)" - tag-select="url" - :button-title=" - sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey }) - " - :shortcuts="$options.shortcuts.link" - icon="link" - /> - <toolbar-button - :prepend="true" - tag="- " - :button-title="__('Add a bullet list')" - icon="list-bulleted" - /> - <toolbar-button - :prepend="true" - tag="1. " - :button-title="__('Add a numbered list')" - icon="list-numbered" - /> - <toolbar-button - :prepend="true" - tag="- [ ] " - :button-title="__('Add a task list')" - icon="list-task" - /> - <toolbar-button - :tag="mdCollapsibleSection" - :prepend="true" - tag-select="Click to expand" - :button-title="__('Add a collapsible section')" - icon="details-block" - /> - <toolbar-button - :tag="mdTable" - :prepend="true" - :button-title="__('Add a table')" - icon="table" - /> - <toolbar-button - class="js-zen-enter" - :prepend="true" - :button-title="__('Go full screen')" - icon="maximize" - /> - </li> - </ul> + <strong>{{ __('New! Suggest changes directly') }}</strong> + <p class="mb-2"> + {{ + __( + 'Suggest code changes which can be immediately applied in one click. Try it out!', + ) + }} + </p> + <gl-button + variant="info" + category="primary" + size="small" + @click="handleSuggestDismissed" + > + {{ __('Got it') }} + </gl-button> + </gl-popover> + </template> + <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" /> + <toolbar-button + tag="[{text}](url)" + tag-select="url" + :button-title=" + sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey }) + " + :shortcuts="$options.shortcuts.link" + icon="link" + /> + <toolbar-button + :prepend="true" + tag="- " + :button-title="__('Add a bullet list')" + icon="list-bulleted" + /> + <toolbar-button + :prepend="true" + tag="1. " + :button-title="__('Add a numbered list')" + icon="list-numbered" + /> + <toolbar-button + :prepend="true" + tag="- [ ] " + :button-title="__('Add a task list')" + icon="list-task" + /> + <toolbar-button + :tag="mdCollapsibleSection" + :prepend="true" + tag-select="Click to expand" + :button-title="__('Add a collapsible section')" + icon="details-block" + /> + <toolbar-button + :tag="mdTable" + :prepend="true" + :button-title="__('Add a table')" + icon="table" + /> + <toolbar-button + class="js-zen-enter" + :prepend="true" + :button-title="__('Go full screen')" + icon="maximize" + /> + </div> + </template> + </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue index 7d2af7983d1..521b1a1075a 100644 --- a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue +++ b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue @@ -1,34 +1,74 @@ <script> -import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlDropdownSectionHeader, + GlSearchBoxByType, +} from '@gitlab/ui'; import { __ } from '~/locale'; +export const EMPTY_NAMESPACE_ID = -1; export const i18n = { DEFAULT_TEXT: __('Select a new namespace'), + DEFAULT_EMPTY_NAMESPACE_TEXT: __('No namespace'), GROUPS: __('Groups'), USERS: __('Users'), }; -const filterByName = (data, searchTerm = '') => - data.filter((d) => d.humanName.toLowerCase().includes(searchTerm)); +const filterByName = (data, searchTerm = '') => { + if (!searchTerm) { + return data; + } + + return data.filter((d) => d.humanName.toLowerCase().includes(searchTerm.toLowerCase())); +}; export default { name: 'NamespaceSelect', components: { GlDropdown, + GlDropdownDivider, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType, }, props: { - data: { - type: Object, - required: true, + groupNamespaces: { + type: Array, + required: false, + default: () => [], + }, + userNamespaces: { + type: Array, + required: false, + default: () => [], }, fullWidth: { type: Boolean, required: false, default: false, }, + defaultText: { + type: String, + required: false, + default: i18n.DEFAULT_TEXT, + }, + includeHeaders: { + type: Boolean, + required: false, + default: true, + }, + emptyNamespaceTitle: { + type: String, + required: false, + default: i18n.DEFAULT_EMPTY_NAMESPACE_TEXT, + }, + includeEmptyNamespace: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -38,21 +78,33 @@ export default { }, computed: { hasUserNamespaces() { - return this.data.user?.length; + return this.userNamespaces.length; }, hasGroupNamespaces() { - return this.data.group?.length; + return this.groupNamespaces.length; }, filteredGroupNamespaces() { if (!this.hasGroupNamespaces) return []; - return filterByName(this.data.group, this.searchTerm); + return filterByName(this.groupNamespaces, this.searchTerm); }, filteredUserNamespaces() { if (!this.hasUserNamespaces) return []; - return filterByName(this.data.user, this.searchTerm); + return filterByName(this.userNamespaces, this.searchTerm); }, selectedNamespaceText() { - return this.selectedNamespace?.humanName || this.$options.i18n.DEFAULT_TEXT; + return this.selectedNamespace?.humanName || this.defaultText; + }, + filteredEmptyNamespaceTitle() { + const { includeEmptyNamespace, emptyNamespaceTitle, searchTerm } = this; + + if (!includeEmptyNamespace) { + return ''; + } + if (!searchTerm) { + return emptyNamespaceTitle; + } + + return emptyNamespaceTitle.toLowerCase().includes(searchTerm.toLowerCase()); }, }, methods: { @@ -60,31 +112,47 @@ export default { this.selectedNamespace = item; this.$emit('select', item); }, + handleSelectEmptyNamespace() { + this.handleSelect({ id: EMPTY_NAMESPACE_ID, humanName: this.emptyNamespaceTitle }); + }, }, i18n, }; </script> <template> - <gl-dropdown :text="selectedNamespaceText" :block="fullWidth"> + <gl-dropdown :text="selectedNamespaceText" :block="fullWidth" data-qa-selector="namespaces_list"> <template #header> <gl-search-box-by-type v-model.trim="searchTerm" /> </template> - <div v-if="hasGroupNamespaces" class="qa-namespaces-list-groups"> - <gl-dropdown-section-header>{{ $options.i18n.GROUPS }}</gl-dropdown-section-header> + <div v-if="filteredEmptyNamespaceTitle"> + <gl-dropdown-item + data-qa-selector="namespaces_list_item" + @click="handleSelectEmptyNamespace()" + > + {{ emptyNamespaceTitle }} + </gl-dropdown-item> + <gl-dropdown-divider /> + </div> + <div v-if="hasGroupNamespaces" data-qa-selector="namespaces_list_groups"> + <gl-dropdown-section-header v-if="includeHeaders">{{ + $options.i18n.GROUPS + }}</gl-dropdown-section-header> <gl-dropdown-item v-for="item in filteredGroupNamespaces" :key="item.id" - class="qa-namespaces-list-item" + data-qa-selector="namespaces_list_item" @click="handleSelect(item)" >{{ item.humanName }}</gl-dropdown-item > </div> - <div v-if="hasUserNamespaces" class="qa-namespaces-list-users"> - <gl-dropdown-section-header>{{ $options.i18n.USERS }}</gl-dropdown-section-header> + <div v-if="hasUserNamespaces" data-qa-selector="namespaces_list_users"> + <gl-dropdown-section-header v-if="includeHeaders">{{ + $options.i18n.USERS + }}</gl-dropdown-section-header> <gl-dropdown-item v-for="item in filteredUserNamespaces" :key="item.id" - class="qa-namespaces-list-item" + data-qa-selector="namespaces_list_item" @click="handleSelect(item)" >{{ item.humanName }}</gl-dropdown-item > diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue deleted file mode 100644 index 3c0ac32e512..00000000000 --- a/app/assets/javascripts/vue_shared/components/pikaday.vue +++ /dev/null @@ -1,48 +0,0 @@ -<script> -import { GlDatepicker } from '@gitlab/ui'; -import { pikadayToString } from '~/lib/utils/datetime_utility'; - -export default { - name: 'DatePicker', - components: { - GlDatepicker, - }, - props: { - selectedDate: { - type: Date, - required: false, - default: null, - }, - minDate: { - type: Date, - required: false, - default: null, - }, - maxDate: { - type: Date, - required: false, - default: null, - }, - }, - methods: { - selected(date) { - this.$emit('newDateSelected', pikadayToString(date)); - }, - toggled() { - this.$emit('hidePicker'); - }, - }, -}; -</script> - -<template> - <gl-datepicker - :value="selectedDate" - :min-date="minDate" - :max-date="maxDate" - start-opened - @close="toggled" - @click="toggled" - @input="selected" - /> -</template> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue index d886a67fff7..5d144c0d699 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue @@ -13,7 +13,7 @@ export default { }, modalId: 'runner-instructions-modal', i18n: { - buttonText: s__('Runners|Show Runner installation instructions'), + buttonText: s__('Runners|Show runner installation instructions'), }, data() { return { 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 deleted file mode 100644 index 460a10e08ed..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue +++ /dev/null @@ -1,49 +0,0 @@ -<script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; - -export default { - name: 'CollapsedCalendarIcon', - directives: { - GlTooltip: GlTooltipDirective, - }, - components: { - GlIcon, - }, - props: { - containerClass: { - type: String, - required: false, - default: '', - }, - text: { - type: String, - required: false, - default: '', - }, - showIcon: { - type: Boolean, - required: false, - default: true, - }, - tooltipText: { - type: String, - required: false, - default: '', - }, - }, - methods: { - click() { - this.$emit('click'); - }, - }, -}; -</script> - -<template> - <div v-gl-tooltip.left.viewport="tooltipText" :class="containerClass" @click="click"> - <gl-icon v-if="showIcon" name="calendar" /> - <slot> - <span> {{ text }} </span> - </slot> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue deleted file mode 100644 index 4531fafbf72..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue +++ /dev/null @@ -1,148 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { dateInWords } from '../../../lib/utils/datetime_utility'; -import datePicker from '../pikaday.vue'; -import collapsedCalendarIcon from './collapsed_calendar_icon.vue'; -import toggleSidebar from './toggle_sidebar.vue'; - -export default { - name: 'SidebarDatePicker', - components: { - datePicker, - toggleSidebar, - collapsedCalendarIcon, - GlLoadingIcon, - }, - props: { - blockClass: { - type: String, - required: false, - default: '', - }, - collapsed: { - type: Boolean, - required: false, - default: true, - }, - showToggleSidebar: { - type: Boolean, - required: false, - default: false, - }, - isLoading: { - type: Boolean, - required: false, - default: false, - }, - editable: { - type: Boolean, - required: false, - default: false, - }, - label: { - type: String, - required: false, - default: __('Date picker'), - }, - selectedDate: { - type: Date, - required: false, - default: null, - }, - minDate: { - type: Date, - required: false, - default: null, - }, - maxDate: { - type: Date, - required: false, - default: null, - }, - }, - data() { - return { - editing: false, - }; - }, - computed: { - selectedAndEditable() { - return this.selectedDate && this.editable; - }, - selectedDateWords() { - return dateInWords(this.selectedDate, true); - }, - collapsedText() { - return this.selectedDateWords ? this.selectedDateWords : __('None'); - }, - }, - methods: { - stopEditing() { - this.editing = false; - }, - toggleDatePicker() { - this.editing = !this.editing; - }, - newDateSelected(date = null) { - this.date = date; - this.editing = false; - this.$emit('saveDate', date); - }, - toggleSidebar() { - this.$emit('toggleCollapse'); - }, - }, -}; -</script> - -<template> - <div :class="blockClass" class="block"> - <div class="issuable-sidebar-header"> - <toggle-sidebar :collapsed="collapsed" @toggle="toggleSidebar" /> - </div> - <collapsed-calendar-icon :text="collapsedText" class="sidebar-collapsed-icon" /> - <div class="title"> - {{ label }} - <gl-loading-icon v-if="isLoading" size="sm" :inline="true" /> - <div class="float-right"> - <button - v-if="editable && !editing" - type="button" - class="btn-blank btn-link btn-primary-hover-link btn-sidebar-action" - @click="toggleDatePicker" - > - {{ __('Edit') }} - </button> - <toggle-sidebar v-if="showToggleSidebar" :collapsed="collapsed" @toggle="toggleSidebar" /> - </div> - </div> - <div class="value"> - <date-picker - v-if="editing" - :selected-date="selectedDate" - :min-date="minDate" - :max-date="maxDate" - :label="label" - @newDateSelected="newDateSelected" - @hidePicker="stopEditing" - /> - <span v-else class="value-content"> - <template v-if="selectedDate"> - <strong>{{ selectedDateWords }}</strong> - <span v-if="selectedAndEditable" class="no-value"> - - - <button - type="button" - class="btn-blank btn-link btn-secondary-hover-link" - @click="newDateSelected(null)" - > - {{ __('remove') }} - </button> - </span> - </template> - <span v-else class="no-value">{{ __('None') }}</span> - </span> - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue index b99083713a8..88977652556 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue @@ -117,7 +117,11 @@ export default { labelCreate: { label }, }, }, - ) => this.updateLabelsInCache(store, label), + ) => { + if (label) { + this.updateLabelsInCache(store, label); + } + }, }); if (labelCreate.errors.length) { [this.error] = labelCreate.errors; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue deleted file mode 100644 index 17904f20341..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue +++ /dev/null @@ -1,37 +0,0 @@ -<script> -import { GlDropdown, GlDropdownForm, GlDropdownDivider } from '@gitlab/ui'; - -export default { - components: { - GlDropdownForm, - GlDropdown, - GlDropdownDivider, - }, - props: { - headerText: { - type: String, - required: true, - }, - text: { - type: String, - required: true, - }, - }, -}; -</script> - -<template> - <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')"> - <template #header> - <p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p> - <gl-dropdown-divider /> - <slot name="search"></slot> - </template> - <gl-dropdown-form> - <slot name="items"></slot> - </gl-dropdown-form> - <template #footer> - <slot name="footer"></slot> - </template> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js new file mode 100644 index 00000000000..9efe0147c37 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -0,0 +1,111 @@ +// Language map from Rouge::Lexer to highlight.js +// Rouge::Lexer - We use it on the BE to determine the language of a source file (https://github.com/rouge-ruby/rouge/blob/master/docs/Languages.md). +// Highlight.js - We use it on the FE to highlight the syntax of a source file (https://github.com/highlightjs/highlight.js/tree/main/src/languages). +export const ROUGE_TO_HLJS_LANGUAGE_MAP = { + bsl: '1c', + actionscript: 'actionscript', + ada: 'ada', + apache: 'apache', + applescript: 'applescript', + armasm: 'armasm', + awk: 'awk', + c: 'c', + ceylon: 'ceylon', + clean: 'clean', + clojure: 'clojure', + cmake: 'cmake', + coffeescript: 'coffeescript', + coq: 'coq', + cpp: 'cpp', + crystal: 'crystal', + csharp: 'csharp', + css: 'css', + d: 'd', + dart: 'dart', + pascal: 'delphi', + diff: 'diff', + jinja: 'django', + docker: 'dockerfile', + batchfile: 'dos', + elixir: 'elixir', + elm: 'elm', + erb: 'erb', + erlang: 'erlang', + fortran: 'fortran', + fsharp: 'fsharp', + gherkin: 'gherkin', + glsl: 'glsl', + go: 'go', + gradle: 'gradle', + groovy: 'groovy', + haml: 'haml', + handlebars: 'handlebars', + haskell: 'haskell', + haxe: 'haxe', + http: 'http', + hylang: 'hy', + ini: 'ini', + isbl: 'isbl', + java: 'java', + javascript: 'javascript', + json: 'json', + julia: 'julia', + kotlin: 'kotlin', + lasso: 'lasso', + tex: 'latex', + common_lisp: 'lisp', + livescript: 'livescript', + llvm: 'llvm', + hlsl: 'lsl', + lua: 'lua', + make: 'makefile', + markdown: 'markdown', + mathematica: 'mathematica', + matlab: 'matlab', + moonscript: 'moonscript', + nginx: 'nginx', + nim: 'nim', + nix: 'nix', + objective_c: 'objectivec', + ocaml: 'ocaml', + perl: 'perl', + php: 'php', + plaintext: 'plaintext', + pony: 'pony', + powershell: 'powershell', + prolog: 'prolog', + properties: 'properties', + protobuf: 'protobuf', + puppet: 'puppet', + python: 'python', + q: 'q', + qml: 'qml', + r: 'r', + reasonml: 'reasonml', + ruby: 'ruby', + rust: 'rust', + sas: 'sas', + scala: 'scala', + scheme: 'scheme', + scss: 'scss', + shell: 'shell', + smalltalk: 'smalltalk', + sml: 'sml', + sqf: 'sqf', + sql: 'sql', + stan: 'stan', + stata: 'stata', + swift: 'swift', + tap: 'tap', + tcl: 'tcl', + twig: 'twig', + typescript: 'typescript', + vala: 'vala', + vb: 'vbnet', + verilog: 'verilog', + vhdl: 'vhdl', + viml: 'vim', + xml: 'xml', + xquery: 'xquery', + yaml: 'yaml', +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index 99895926653..5aae1812de3 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -1,36 +1,31 @@ <script> -import { GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui'; import LineNumbers from '~/vue_shared/components/line_numbers.vue'; import { sanitize } from '~/lib/dompurify'; +import { ROUGE_TO_HLJS_LANGUAGE_MAP } from './constants'; +import { wrapLines } from './utils'; const LINE_SELECT_CLASS_NAME = 'hll'; export default { components: { LineNumbers, + GlLoadingIcon, }, directives: { SafeHtml: GlSafeHtmlDirective, }, props: { - content: { - type: String, + blob: { + type: Object, required: true, }, - language: { - type: String, - required: false, - default: 'plaintext', - }, - autoDetect: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { languageDefinition: null, + content: this.blob.rawTextBlob, + language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language], hljs: null, }; }, @@ -42,14 +37,14 @@ export default { let highlightedContent; if (this.hljs) { - if (this.autoDetect) { + if (!this.language) { highlightedContent = this.hljs.highlightAuto(this.content).value; } else if (this.languageDefinition) { highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value; } } - return this.wrapLines(highlightedContent); + return wrapLines(highlightedContent); }, }, watch: { @@ -63,14 +58,14 @@ export default { async mounted() { this.hljs = await this.loadHighlightJS(); - if (!this.autoDetect) { + if (this.language) { this.languageDefinition = await this.loadLanguage(); } }, methods: { loadHighlightJS() { - // With auto-detect enabled we load all common languages else we load only the core (smallest footprint) - return this.autoDetect ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); + // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint) + return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); }, async loadLanguage() { let languageDefinition; @@ -84,15 +79,6 @@ export default { return languageDefinition; }, - wrapLines(content) { - return ( - content && - content - .split('\n') - .map((line, i) => `<span id="LC${i + 1}" class="line">${line}</span>`) - .join('\r\n') - ); - }, selectLine() { const hash = sanitize(this.$route.hash); const lineToSelect = hash && this.$el.querySelector(hash); @@ -115,9 +101,16 @@ export default { }; </script> <template> - <div class="file-content code js-syntax-highlight" :class="$options.userColorScheme"> + <gl-loading-icon v-if="!highlightedContent" size="sm" class="gl-my-5" /> + <div + v-else + class="file-content code js-syntax-highlight blob-content gl-display-flex" + :class="$options.userColorScheme" + data-type="simple" + data-qa-selector="blob_viewer_file_content" + > <line-numbers :lines="lineNumbers" /> - <pre class="code"><code v-safe-html="highlightedContent"></code> + <pre class="code gl-pb-0!"><code v-safe-html="highlightedContent"></code> </pre> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js new file mode 100644 index 00000000000..e64e564bf61 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js @@ -0,0 +1,26 @@ +export const wrapLines = (content) => { + return ( + content && + content + .split('\n') + .map((line, i) => { + let formattedLine; + const idAttribute = `id="LC${i + 1}"`; + + if (line.includes('<span class="hljs') && !line.includes('</span>')) { + /** + * In some cases highlight.js will wrap multiple lines in a span, in these cases we want to append the line number to the existing span + * + * example (before): <span class="hljs-code">```bash + * example (after): <span id="LC67" class="hljs-code">```bash + */ + formattedLine = line.replace(/(?=class="hljs)/, `${idAttribute} `); + } else { + formattedLine = `<span ${idAttribute} class="line">${line}</span>`; + } + + return formattedLine; + }) + .join('\n') + ); +}; diff --git a/app/assets/javascripts/vue_shared/components/svg_gradient.vue b/app/assets/javascripts/vue_shared/components/svg_gradient.vue deleted file mode 100644 index 5ce45d492f9..00000000000 --- a/app/assets/javascripts/vue_shared/components/svg_gradient.vue +++ /dev/null @@ -1,34 +0,0 @@ -<script> -export default { - props: { - colors: { - type: Array, - required: true, - validator(value) { - return value.length === 2; - }, - }, - opacity: { - type: Array, - required: true, - validator(value) { - return value.length === 2; - }, - }, - identifierName: { - type: String, - required: true, - }, - }, -}; -</script> -<template> - <svg height="0" width="0"> - <defs> - <linearGradient :id="identifierName"> - <stop :stop-color="colors[0]" :stop-opacity="opacity[0]" offset="0%" /> - <stop :stop-color="colors[1]" :stop-opacity="opacity[1]" offset="100%" /> - </linearGradient> - </defs> - </svg> -</template> diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue index 0a7a22ed3a8..62de76e46b5 100644 --- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue +++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue @@ -41,6 +41,16 @@ export default { required: false, default: false, }, + inputFieldName: { + type: String, + required: false, + default: 'upload_file', + }, + shouldUpdateInputOnFileDrop: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -84,6 +94,30 @@ export default { return; } + // NOTE: This is a temporary solution to integrate dropzone into a Rails + // form. On file drop if `shouldUpdateInputOnFileDrop` is true, the file + // input value is updated. So that when the form is submitted — the file + // value would be send together with the form data. This solution should + // be removed when License file upload page is fully migrated: + // https://gitlab.com/gitlab-org/gitlab/-/issues/352501 + // NOTE: as per https://caniuse.com/mdn-api_htmlinputelement_files, IE11 + // is not able to set input.files property, thought the user would still + // be able to use the file picker dialogue option, by clicking the + // "openFileUpload" button + if (this.shouldUpdateInputOnFileDrop) { + // Since FileList cannot be easily manipulated, to match requirement of + // singleFileSelection, we're throwing an error if multiple files were + // dropped on the dropzone + // NOTE: we can drop this logic together with + // `shouldUpdateInputOnFileDrop` flag + if (this.singleFileSelection && files.length > 1) { + this.$emit('error'); + return; + } + + this.$refs.fileUpload.files = files; + } + this.$emit('change', this.singleFileSelection ? files[0] : files); }, ondragenter(e) { @@ -116,6 +150,7 @@ export default { <slot> <button class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + type="button" @click="openFileUpload" > <div @@ -147,7 +182,7 @@ export default { <input ref="fileUpload" type="file" - name="upload_file" + :name="inputFieldName" :accept="validFileMimetypes" class="hide" :multiple="!singleFileSelection" 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 f02cd5c4e2e..82022d1f4d6 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -1,9 +1,9 @@ <script> -import $ from 'jquery'; import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue'; const KEY_EDIT = 'edit'; const KEY_WEB_IDE = 'webide'; @@ -16,6 +16,7 @@ export default { GlModal, GlSprintf, GlLink, + ConfirmForkModal, }, i18n: { modal: { @@ -103,11 +104,22 @@ export default { required: false, default: false, }, + forkPath: { + type: String, + required: false, + default: '', + }, + forkModalId: { + type: String, + required: false, + default: '', + }, }, data() { return { selection: KEY_WEB_IDE, showEnableGitpodModal: false, + showForkModal: false, }; }, computed: { @@ -128,7 +140,7 @@ export default { return; } - this.showJQueryModal('#modal-confirm-fork-edit'); + this.showModal('showForkModal'); }, } : { href: this.editUrl }; @@ -171,7 +183,7 @@ export default { return; } - this.showJQueryModal('#modal-confirm-fork-webide'); + this.showModal('showForkModal'); }, } : { href: this.webIdeUrl }; @@ -247,9 +259,6 @@ export default { select(key) { this.selection = key; }, - showJQueryModal(id) { - $(id).modal('show'); - }, showModal(dataKey) { this[dataKey] = true; }, @@ -282,5 +291,11 @@ export default { </template> </gl-sprintf> </gl-modal> + <confirm-fork-modal + v-if="showWebIdeButton || showEditButton" + v-model="showForkModal" + :modal-id="forkModalId" + :fork-path="forkPath" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index af0235bfc69..8008b85bbdb 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -31,10 +31,6 @@ export default { type: Object, required: true, }, - enableLabelPermalinks: { - type: Boolean, - required: true, - }, labelFilterParam: { type: String, required: false, @@ -121,7 +117,10 @@ export default { }, showIssuableMeta() { return Boolean( - this.hasSlotContents('status') || this.showDiscussions || this.issuable.assignees, + this.hasSlotContents('status') || + this.hasSlotContents('statistics') || + this.showDiscussions || + this.issuable.assignees, ); }, issuableNotesLink() { @@ -139,11 +138,8 @@ export default { return label.title || label.name; }, labelTarget(label) { - if (this.enableLabelPermalinks) { - const value = encodeURIComponent(this.labelTitle(label)); - return `?${this.labelFilterParam}[]=${value}`; - } - return '#'; + const value = encodeURIComponent(this.labelTitle(label)); + return `?${this.labelFilterParam}[]=${value}`; }, /** * This is needed as an independent method since diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index 2f8401b45f0..028d48e7e8a 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -15,6 +15,7 @@ const VueDraggable = () => import('vuedraggable'); export default { vueDraggableAttributes: { animation: 200, + forceFallback: true, ghostClass: 'gl-visibility-hidden', tag: 'ul', }, @@ -78,6 +79,11 @@ export default { required: false, default: null, }, + truncateCounts: { + type: Boolean, + required: false, + default: false, + }, currentTab: { type: String, required: true, @@ -127,11 +133,6 @@ export default { required: false, default: 2, }, - enableLabelPermalinks: { - type: Boolean, - required: false, - default: true, - }, labelFilterParam: { type: String, required: false, @@ -261,6 +262,7 @@ export default { :tabs="tabs" :tab-counts="tabCounts" :current-tab="currentTab" + :truncate-counts="truncateCounts" @click="$emit('click-tab', $event)" > <template #nav-actions> @@ -314,7 +316,6 @@ export default { :data-qa-issuable-title="issuable.title" :issuable-symbol="issuableSymbol" :issuable="issuable" - :enable-label-permalinks="enableLabelPermalinks" :label-filter-param="labelFilterParam" :show-checkbox="showBulkEditSidebar" :checked="issuableChecked(issuable)" diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue index 9bf54e98cc4..0691bc02b5c 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue @@ -1,5 +1,6 @@ <script> import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; +import { numberToMetricPrefix } from '~/lib/utils/number_utils'; import { formatNumber } from '~/locale'; export default { @@ -22,6 +23,11 @@ export default { type: String, required: true, }, + truncateCounts: { + type: Boolean, + required: false, + default: false, + }, }, methods: { isTabActive(tabName) { @@ -31,7 +37,7 @@ export default { return Number.isInteger(this.tabCounts[tab.name]); }, formatNumber(count) { - return formatNumber(count); + return this.truncateCounts ? numberToMetricPrefix(count) : formatNumber(count); }, }, }; diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue index d7da533d055..ee7e113af72 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue @@ -102,7 +102,7 @@ export default { </div> </div> <span> - {{ __('Opened') }} + {{ __('Created') }} <time-ago-tooltip data-testid="startTimeItem" :time="createdAt" /> {{ __('by') }} </span> diff --git a/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue index 99dcccd12ed..774267639fc 100644 --- a/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue @@ -1,8 +1,8 @@ <script> import { GlIcon } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import Cookies from 'js-cookie'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils'; + import { USER_COLLAPSED_GUTTER_COOKIE } from '../constants'; export default { @@ -10,7 +10,7 @@ export default { GlIcon, }, data() { - const userExpanded = !parseBoolean(Cookies.get(USER_COLLAPSED_GUTTER_COOKIE)); + const userExpanded = !parseBoolean(getCookie(USER_COLLAPSED_GUTTER_COOKIE)); // We're deliberately keeping two different props for sidebar status; // 1. userExpanded reflects value based on cookie `collapsed_gutter`. @@ -46,7 +46,7 @@ export default { this.isExpanded = !this.isExpanded; this.userExpanded = this.isExpanded; - Cookies.set(USER_COLLAPSED_GUTTER_COOKIE, !this.userExpanded); + setCookie(USER_COLLAPSED_GUTTER_COOKIE, !this.userExpanded); this.updatePageContainerClass(); }, }, diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index f67e590e2ce..1f3cc663848 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -11,7 +11,7 @@ export default { WelcomePage, LegacyContainer, CreditCardVerification: () => - import('ee_component/pages/groups/new/components/credit_card_verification.vue'), + import('ee_component/namespaces/verification/components/credit_card_verification.vue'), }, directives: { SafeHtml, diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue index d1630c9ac13..3afd1f9410b 100644 --- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue +++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue @@ -14,7 +14,7 @@ export default { components: { GlButton, }, - inject: ['projectPath'], + inject: ['projectFullPath'], props: { feature: { type: Object, @@ -47,7 +47,7 @@ export default { try { const { mutationSettings } = this; const { data } = await this.$apollo.mutate( - mutationSettings.getMutationPayload(this.projectPath), + mutationSettings.getMutationPayload(this.projectFullPath), ); const { errors, successPath } = data[mutationSettings.mutationId]; 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 index 12f2bc71505..f6d85599dba 100644 --- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue +++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue @@ -102,8 +102,8 @@ export default { error(error) { this.showError(error); }, - result({ loading }) { - if (loading) { + result({ loading, data }) { + if (loading || !data) { return; } diff --git a/app/assets/javascripts/work_items/graphql/fragmentTypes.json b/app/assets/javascripts/work_items/graphql/fragmentTypes.json deleted file mode 100644 index 3b837e84ee9..00000000000 --- a/app/assets/javascripts/work_items/graphql/fragmentTypes.json +++ /dev/null @@ -1 +0,0 @@ -{"__schema":{"types":[{"kind":"INTERFACE","name":"LocalWorkItemWidget","possibleTypes":[{"name":"LocalTitleWidget"}]}]}} diff --git a/app/assets/javascripts/work_items/graphql/project_work_item_types.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_item_types.query.graphql new file mode 100644 index 00000000000..e7e3ce8c1ae --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/project_work_item_types.query.graphql @@ -0,0 +1,11 @@ +query projectWorkItemTypes($fullPath: ID!) { + workspace: project(fullPath: $fullPath) { + id + workItemTypes { + nodes { + id + name + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index fb536a425c0..676fffb12d8 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -1,23 +1,14 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import createDefaultClient from '~/lib/graphql'; import workItemQuery from './work_item.query.graphql'; -import introspectionQueryResultData from './fragmentTypes.json'; import { resolvers } from './resolvers'; import typeDefs from './typedefs.graphql'; -const fragmentMatcher = new IntrospectionFragmentMatcher({ - introspectionQueryResultData, -}); - export function createApolloProvider() { Vue.use(VueApollo); const defaultClient = createDefaultClient(resolvers, { - cacheConfig: { - fragmentMatcher, - }, typeDefs, }); diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 7cc8a23b7b1..10fae9b9cc0 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -5,11 +5,15 @@ import { createApolloProvider } from './graphql/provider'; export const initWorkItemsRoot = () => { const el = document.querySelector('#js-work-items'); + const { fullPath } = el.dataset; return new Vue({ el, router: createRouter(el.dataset.fullPath), apolloProvider: createApolloProvider(), + provide: { + fullPath, + }, render(createElement) { return createElement(App); }, diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index 12bad5606d4..6c3bcf8f6a8 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -1,6 +1,8 @@ <script> -import { GlButton, GlAlert } from '@gitlab/ui'; +import { GlButton, GlAlert, GlLoadingIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { s__ } from '~/locale'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; +import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; import ItemTitle from '../components/item_title.vue'; @@ -8,14 +10,55 @@ export default { components: { GlButton, GlAlert, + GlLoadingIcon, + GlDropdown, + GlDropdownItem, ItemTitle, }, + inject: ['fullPath'], + props: { + isModal: { + type: Boolean, + required: false, + default: false, + }, + initialTitle: { + type: String, + required: false, + default: '', + }, + }, data() { return { - title: '', - error: false, + title: this.initialTitle, + error: null, + workItemTypes: [], + selectedWorkItemType: null, }; }, + apollo: { + workItemTypes: { + query: projectWorkItemTypesQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update(data) { + return data.workspace?.workItemTypes?.nodes; + }, + error() { + this.error = s__( + 'WorkItem|Something went wrong when fetching work item types. Please try again', + ); + }, + }, + }, + computed: { + dropdownButtonText() { + return this.selectedWorkItemType?.name || s__('WorkItem|Type'); + }, + }, methods: { async createWorkItem() { try { @@ -35,35 +78,82 @@ export default { }, }, } = response; - this.$router.push({ name: 'workItem', params: { id } }); + if (!this.isModal) { + this.$router.push({ name: 'workItem', params: { id } }); + } else { + this.$emit('onCreate', this.title); + } } catch { - this.error = true; + this.error = s__( + 'WorkItem|Something went wrong when creating a work item. Please try again', + ); } }, handleTitleInput(title) { this.title = title; }, + handleCancelClick() { + if (!this.isModal) { + this.$router.go(-1); + return; + } + this.$emit('closeModal'); + }, + selectWorkItemType(type) { + this.selectedWorkItemType = type; + }, }, }; </script> <template> <form @submit.prevent="createWorkItem"> - <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{ - __('Something went wrong when creating a work item. Please try again') - }}</gl-alert> - <item-title data-testid="title-input" @title-input="handleTitleInput" /> - <div class="gl-bg-gray-10 gl-py-5 gl-px-6"> + <gl-alert v-if="error" variant="danger" @dismiss="error = null">{{ error }}</gl-alert> + <div :class="{ 'gl-px-5': isModal }" data-testid="content"> + <item-title + :initial-title="title" + data-testid="title-input" + @title-input="handleTitleInput" + /> + <div> + <gl-dropdown :text="dropdownButtonText"> + <gl-loading-icon + v-if="$apollo.queries.workItemTypes.loading" + size="md" + data-testid="loading-types" + /> + <template v-else> + <gl-dropdown-item + v-for="type in workItemTypes" + :key="type.id" + @click="selectWorkItemType(type)" + > + {{ type.name }} + </gl-dropdown-item> + </template> + </gl-dropdown> + </div> + </div> + <div + class="gl-bg-gray-10 gl-py-5 gl-px-6 gl-mt-4" + :class="{ 'gl-display-flex gl-justify-content-end': isModal }" + > <gl-button variant="confirm" :disabled="title.length === 0" - class="gl-mr-3" + :class="{ 'gl-mr-3': !isModal }" data-testid="create-button" type="submit" > - {{ __('Create') }} + {{ s__('WorkItem|Create work item') }} </gl-button> - <gl-button type="button" data-testid="cancel-button" @click="$router.go(-1)"> + <gl-button + type="button" + data-testid="cancel-button" + class="gl-order-n1" + :class="{ 'gl-mr-3': isModal }" + @click="handleCancelClick" + > {{ __('Cancel') }} </gl-button> </div> diff --git a/app/assets/javascripts/work_items_hierarchy/components/app.vue b/app/assets/javascripts/work_items_hierarchy/components/app.vue new file mode 100644 index 00000000000..621cfe5bace --- /dev/null +++ b/app/assets/javascripts/work_items_hierarchy/components/app.vue @@ -0,0 +1,101 @@ +<script> +import { GlBanner } from '@gitlab/ui'; +import Cookies from 'js-cookie'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import RESPONSE from '../static_response'; +import { WORK_ITEMS_SURVEY_COOKIE_NAME, workItemTypes } from '../constants'; +import Hierarchy from './hierarchy.vue'; + +export default { + components: { + GlBanner, + Hierarchy, + }, + inject: ['illustrationPath', 'licensePlan'], + data() { + return { + bannerVisible: !parseBoolean(Cookies.get(WORK_ITEMS_SURVEY_COOKIE_NAME)), + workItemHierarchy: RESPONSE[this.licensePlan], + }; + }, + computed: { + hasUnavailableStructure() { + return this.workItemTypes.unavailable.length > 0; + }, + workItemTypes() { + return this.workItemHierarchy.reduce( + (itemTypes, item) => { + const skipItem = workItemTypes[item.type].isWorkItem && !window.gon?.features?.workItems; + + if (skipItem) { + return itemTypes; + } + const key = item.available ? 'available' : 'unavailable'; + const nestedTypes = item.nestedTypes?.map((type) => workItemTypes[type]); + + itemTypes[key].push({ + ...item, + ...workItemTypes[item.type], + nestedTypes, + }); + + return itemTypes; + }, + { available: [], unavailable: [] }, + ); + }, + }, + methods: { + handleClose() { + Cookies.set(WORK_ITEMS_SURVEY_COOKIE_NAME, 'true', { expires: 365 * 10 }); + this.bannerVisible = false; + }, + }, +}; +</script> + +<template> + <div> + <gl-banner + v-if="bannerVisible" + class="gl-mt-4 gl-px-5!" + :title="s__('Hierarchy|Help us improve work items in GitLab!')" + :button-text="s__('Hierarchy|Take the work items survey')" + button-link="https://forms.gle/u1BmRp8rTbwj52iq5" + :svg-path="illustrationPath" + @close="handleClose" + > + <p> + {{ + s__( + 'Hierarchy|Is there a framework or type of work item you wish you had access to in GitLab? Give us your feedback and help us build the experiences valuable to you.', + ) + }} + </p> + </gl-banner> + <h3 class="gl-mt-5!">{{ s__('Hierarchy|Planning hierarchy') }}</h3> + <p> + {{ + s__( + 'Hierarchy|Deliver value more efficiently by breaking down necessary work into a hierarchical structure. This structure helps teams understand scope, priorities, and how work cascades up toward larger goals.', + ) + }} + </p> + + <div class="gl-font-weight-bold gl-mb-2">{{ s__('Hierarchy|Current structure') }}</div> + <p class="gl-mb-3!">{{ s__('Hierarchy|You can start using these items now.') }}</p> + <hierarchy :work-item-types="workItemTypes.available" /> + + <div + v-if="hasUnavailableStructure" + data-testid="unavailable-structure" + class="gl-font-weight-bold gl-mt-5 gl-mb-2" + > + {{ s__('Hierarchy|Unavailable structure') }} + </div> + <p v-if="hasUnavailableStructure" class="gl-mb-3!"> + {{ s__('Hierarchy|These items are unavailable in the current structure.') }} + </p> + <hierarchy :work-item-types="workItemTypes.unavailable" /> + </div> +</template> diff --git a/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue b/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue new file mode 100644 index 00000000000..9b81218b6e4 --- /dev/null +++ b/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue @@ -0,0 +1,119 @@ +<script> +import { GlIcon, GlBadge } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlBadge, + }, + props: { + workItemTypes: { + type: Array, + required: true, + }, + }, + methods: { + isLastItem(index, workItem) { + const hasMoreThanOneItem = workItem.nestedTypes.length > 1; + const isLastItemInArray = index === workItem.nestedTypes.length - 1; + + return isLastItemInArray && hasMoreThanOneItem; + }, + nestedWorkItemTypeMargin(index, workItem) { + const isLastItemInArray = index === workItem.nestedTypes.length - 1; + const hasMoreThanOneItem = workItem.nestedTypes.length > 1; + + if (isLastItemInArray && hasMoreThanOneItem) { + return 'gl-ml-0'; + } + + return 'gl-ml-6'; + }, + }, +}; +</script> +<template> + <div> + <div + v-for="workItem in workItemTypes" + :key="workItem.id" + class="gl-mb-3" + :class="{ flex: !workItem.available }" + > + <span + class="gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base gl-pl-2 gl-pt-2 gl-pb-2 gl-pr-3 gl-display-inline-flex gl-align-items-center gl-justify-content-center gl-line-height-normal" + data-testid="work-item-wrapper" + > + <span + :style="{ + backgroundColor: workItem.backgroundColor, + color: workItem.color, + }" + class="gl-rounded-base gl-mr-2 gl-display-inline-flex justify-content-center align-items-center hierarchy-icon-wrapper" + > + <gl-icon :size="workItem.iconSize || 12" :name="workItem.icon" /> + </span> + + {{ workItem.title }} + </span> + + <gl-badge + v-if="!workItem.available" + variant="info" + icon="license" + size="sm" + class="gl-ml-3 gl-align-self-center" + >{{ workItem.license }}</gl-badge + > + + <div v-if="workItem.nestedTypes" :class="{ 'gl-relative': workItem.nestedTypes.length > 1 }"> + <svg + v-if="workItem.nestedTypes.length > 1" + class="hierarchy-rounded-arrow-tail gl-text-gray-400" + data-testid="hierarchy-rounded-arrow-tail" + width="2" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <line + x1="0.75" + y1="1" + x2="0.75" + y2="100%" + stroke="currentColor" + stroke-width="1.5" + stroke-linecap="round" + /> + </svg> + <template v-for="(nestedWorkItem, index) in workItem.nestedTypes"> + <div :key="nestedWorkItem.id" class="gl-display-block gl-mt-2 gl-ml-6"> + <gl-icon name="arrow-down" class="gl-text-gray-400" /> + </div> + <gl-icon + v-if="isLastItem(index, workItem)" + :key="nestedWorkItem.id" + name="level-up" + class="gl-text-gray-400 gl-ml-2 hierarchy-rounded-arrow" + /> + <span + :key="nestedWorkItem.id" + class="gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base gl-pl-2 gl-pt-2 gl-pb-2 gl-pr-3 gl-display-inline-flex gl-align-items-center gl-justify-content-center gl-mt-2 gl-line-height-normal" + :class="nestedWorkItemTypeMargin(index, workItem)" + > + <span + :style="{ + backgroundColor: nestedWorkItem.backgroundColor, + color: nestedWorkItem.color, + }" + class="gl-rounded-base gl-mr-2 gl-display-inline-flex justify-content-center align-items-center hierarchy-icon-wrapper" + > + <gl-icon :size="nestedWorkItem.iconSize || 12" :name="nestedWorkItem.icon" /> + </span> + + {{ nestedWorkItem.title }} + </span> + </template> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items_hierarchy/constants.js b/app/assets/javascripts/work_items_hierarchy/constants.js new file mode 100644 index 00000000000..c14fe67af4d --- /dev/null +++ b/app/assets/javascripts/work_items_hierarchy/constants.js @@ -0,0 +1,62 @@ +import { __ } from '~/locale'; + +export const WORK_ITEMS_SURVEY_COOKIE_NAME = 'hide_work_items_hierarchy_survey'; + +/** + * Hard-coded strings since we're rendering hierarchy + * items from mock responses. Remove this when we + * have a real hierarchy endpoint. + */ +export const LICENSE_PLAN = { + FREE: 'free', + PREMIUM: 'premium', + ULTIMATE: 'ultimate', +}; + +export const workItemTypes = { + EPIC: { + title: __('Epic'), + icon: 'epic', + color: '#694CC0', + backgroundColor: '#E1D8F9', + }, + ISSUE: { + title: __('Issue'), + icon: 'issues', + color: '#1068BF', + backgroundColor: '#CBE2F9', + }, + TASK: { + title: __('Task'), + icon: 'task-done', + color: '#217645', + backgroundColor: '#C3E6CD', + isWorkItem: true, + }, + INCIDENT: { + title: __('Incident'), + icon: 'issue-type-incident', + backgroundColor: '#db2a0f', + color: '#FDD4CD', + iconSize: 16, + }, + SUB_EPIC: { + title: __('Child epic'), + icon: 'epic', + color: '#AB6100', + backgroundColor: '#F5D9A8', + }, + REQUIREMENT: { + title: __('Requirement'), + icon: 'requirements', + color: '#0068c5', + backgroundColor: '#c5e3fb', + }, + TEST_CASE: { + title: __('Test case'), + icon: 'issue-type-test-case', + backgroundColor: '#007a3f', + color: '#bae8cb', + iconSize: 16, + }, +}; diff --git a/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js b/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js new file mode 100644 index 00000000000..61d93acdb91 --- /dev/null +++ b/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js @@ -0,0 +1,10 @@ +import { LICENSE_PLAN } from './constants'; + +export function inferLicensePlan({ hasSubEpics, hasEpics }) { + if (hasSubEpics) { + return LICENSE_PLAN.ULTIMATE; + } else if (hasEpics) { + return LICENSE_PLAN.PREMIUM; + } + return LICENSE_PLAN.FREE; +} diff --git a/app/assets/javascripts/work_items_hierarchy/static_response.js b/app/assets/javascripts/work_items_hierarchy/static_response.js new file mode 100644 index 00000000000..d1e2e486082 --- /dev/null +++ b/app/assets/javascripts/work_items_hierarchy/static_response.js @@ -0,0 +1,142 @@ +const FREE_TIER = 'free'; +const ULTIMATE_TIER = 'ultimate'; +const PREMIUM_TIER = 'premium'; + +const RESPONSE = { + [FREE_TIER]: [ + { + id: '1', + type: 'ISSUE', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '2', + type: 'TASK', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '3', + type: 'INCIDENT', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '4', + type: 'EPIC', + available: false, + license: 'Premium', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + { + id: '5', + type: 'SUB_EPIC', + available: false, + license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + { + id: '6', + type: 'REQUIREMENT', + available: false, + license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + { + id: '7', + type: 'TEST_CASE', + available: false, + license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + ], + + [PREMIUM_TIER]: [ + { + id: '1', + type: 'EPIC', + available: true, + license: null, + nestedTypes: ['ISSUE'], + }, + { + id: '2', + type: 'TASK', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '3', + type: 'INCIDENT', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '5', + type: 'SUB_EPIC', + available: false, + license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + { + id: '6', + type: 'REQUIREMENT', + available: false, + license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + { + id: '7', + type: 'TEST_CASE', + available: false, + license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings + nestedTypes: null, + }, + ], + + [ULTIMATE_TIER]: [ + { + id: '1', + type: 'EPIC', + available: true, + license: null, + nestedTypes: ['SUB_EPIC', 'ISSUE'], + }, + { + id: '2', + type: 'TASK', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '3', + type: 'INCIDENT', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '6', + type: 'REQUIREMENT', + available: true, + license: null, + nestedTypes: null, + }, + { + id: '7', + type: 'TEST_CASE', + available: true, + license: null, + nestedTypes: null, + }, + ], +}; + +export default RESPONSE; diff --git a/app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js b/app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js new file mode 100644 index 00000000000..2258c725301 --- /dev/null +++ b/app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import App from './components/app.vue'; +import { inferLicensePlan } from './hierarchy_util'; + +export const initWorkItemsHierarchy = () => { + const el = document.querySelector('#js-work-items-hierarchy'); + + const { illustrationPath, hasEpics, hasSubEpics } = el.dataset; + + const licensePlan = inferLicensePlan({ + hasEpics: parseBoolean(hasEpics), + hasSubEpics: parseBoolean(hasSubEpics), + }); + + return new Vue({ + el, + provide: { + illustrationPath, + licensePlan, + }, + render(createElement) { + return createElement(App); + }, + }); +}; |