diff options
Diffstat (limited to 'app/assets/javascripts')
157 files changed, 1944 insertions, 905 deletions
diff --git a/app/assets/javascripts/admin/users/tabs.js b/app/assets/javascripts/admin/users/tabs.js index 9ada77396c7..cbaab7df4e9 100644 --- a/app/assets/javascripts/admin/users/tabs.js +++ b/app/assets/javascripts/admin/users/tabs.js @@ -1,11 +1,20 @@ +import Api from '~/api'; import { historyPushState } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; const COHORTS_PANE = 'cohorts'; +const COHORTS_PANE_TAB_CLICK_EVENT = 'i_analytics_cohorts'; const tabClickHandler = (e) => { const { hash } = e.currentTarget; - const tab = hash === `#${COHORTS_PANE}` ? COHORTS_PANE : null; + + let tab = null; + + if (hash === `#${COHORTS_PANE}`) { + tab = COHORTS_PANE; + Api.trackRedisHllUserEvent(COHORTS_PANE_TAB_CLICK_EVENT); + } + const newUrl = mergeUrlParams({ tab }, window.location.href); historyPushState(newUrl); }; diff --git a/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js b/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js deleted file mode 100644 index 6fba3c56cfe..00000000000 --- a/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js +++ /dev/null @@ -1,87 +0,0 @@ -import { s__, __, sprintf } from '~/locale'; -import query from '../graphql/queries/instance_count.query.graphql'; - -const noDataMessage = s__('InstanceStatistics|No data available.'); - -export default [ - { - loadChartError: sprintf( - s__( - 'InstanceStatistics|Could not load the pipelines chart. Please refresh the page to try again.', - ), - ), - noDataMessage, - chartTitle: s__('InstanceStatistics|Pipelines'), - yAxisTitle: s__('InstanceStatistics|Items'), - xAxisTitle: s__('InstanceStatistics|Month'), - queries: [ - { - query, - title: s__('InstanceStatistics|Pipelines total'), - identifier: 'PIPELINES', - loadError: sprintf( - s__('InstanceStatistics|There was an error fetching the total pipelines'), - ), - }, - { - query, - title: s__('InstanceStatistics|Pipelines succeeded'), - identifier: 'PIPELINES_SUCCEEDED', - loadError: sprintf( - s__('InstanceStatistics|There was an error fetching the successful pipelines'), - ), - }, - { - query, - title: s__('InstanceStatistics|Pipelines failed'), - identifier: 'PIPELINES_FAILED', - loadError: sprintf( - s__('InstanceStatistics|There was an error fetching the failed pipelines'), - ), - }, - { - query, - title: s__('InstanceStatistics|Pipelines canceled'), - identifier: 'PIPELINES_CANCELED', - loadError: sprintf( - s__('InstanceStatistics|There was an error fetching the cancelled pipelines'), - ), - }, - { - query, - title: s__('InstanceStatistics|Pipelines skipped'), - identifier: 'PIPELINES_SKIPPED', - loadError: sprintf( - s__('InstanceStatistics|There was an error fetching the skipped pipelines'), - ), - }, - ], - }, - { - loadChartError: sprintf( - s__( - 'InstanceStatistics|Could not load the issues and merge requests chart. Please refresh the page to try again.', - ), - ), - noDataMessage, - chartTitle: s__('InstanceStatistics|Issues & Merge Requests'), - yAxisTitle: s__('InstanceStatistics|Items'), - xAxisTitle: s__('InstanceStatistics|Month'), - queries: [ - { - query, - title: __('Issues'), - identifier: 'ISSUES', - loadError: sprintf(s__('InstanceStatistics|There was an error fetching the issues')), - }, - { - query, - title: __('Merge requests'), - identifier: 'MERGE_REQUESTS', - loadError: sprintf( - s__('InstanceStatistics|There was an error fetching the merge requests'), - ), - }, - ], - }, -]; diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql deleted file mode 100644 index 40cef95c2e7..00000000000 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql +++ /dev/null @@ -1,4 +0,0 @@ -fragment Count on InstanceStatisticsMeasurement { - count - recordedAt -} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql deleted file mode 100644 index f14c2658674..00000000000 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql +++ /dev/null @@ -1,34 +0,0 @@ -#import "../fragments/count.fragment.graphql" - -query getInstanceCounts { - projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: 1) { - nodes { - ...Count - } - } - groups: instanceStatisticsMeasurements(identifier: GROUPS, first: 1) { - nodes { - ...Count - } - } - users: instanceStatisticsMeasurements(identifier: USERS, first: 1) { - nodes { - ...Count - } - } - issues: instanceStatisticsMeasurements(identifier: ISSUES, first: 1) { - nodes { - ...Count - } - } - mergeRequests: instanceStatisticsMeasurements(identifier: MERGE_REQUESTS, first: 1) { - nodes { - ...Count - } - } - pipelines: instanceStatisticsMeasurements(identifier: PIPELINES, first: 1) { - nodes { - ...Count - } - } -} diff --git a/app/assets/javascripts/analytics/instance_statistics/components/app.vue b/app/assets/javascripts/analytics/usage_trends/components/app.vue index 3bf41eaa008..c6436160ea2 100644 --- a/app/assets/javascripts/analytics/instance_statistics/components/app.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/app.vue @@ -1,16 +1,16 @@ <script> import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants'; import ChartsConfig from './charts_config'; -import InstanceCounts from './instance_counts.vue'; -import InstanceStatisticsCountChart from './instance_statistics_count_chart.vue'; import ProjectsAndGroupsChart from './projects_and_groups_chart.vue'; +import UsageCounts from './usage_counts.vue'; +import UsageTrendsCountChart from './usage_trends_count_chart.vue'; import UsersChart from './users_chart.vue'; export default { - name: 'InstanceStatisticsApp', + name: 'UsageTrendsApp', components: { - InstanceCounts, - InstanceStatisticsCountChart, + UsageCounts, + UsageTrendsCountChart, UsersChart, ProjectsAndGroupsChart, }, @@ -23,7 +23,7 @@ export default { <template> <div> - <instance-counts /> + <usage-counts /> <users-chart :start-date="$options.START_DATE" :end-date="$options.TODAY" @@ -34,7 +34,7 @@ export default { :end-date="$options.TODAY" :total-data-points="$options.TOTAL_DAYS_TO_SHOW" /> - <instance-statistics-count-chart + <usage-trends-count-chart v-for="chartOptions in $options.configs" :key="chartOptions.chartTitle" :queries="chartOptions.queries" diff --git a/app/assets/javascripts/analytics/usage_trends/components/charts_config.js b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js new file mode 100644 index 00000000000..b6b440b710f --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js @@ -0,0 +1,73 @@ +import { s__, __, sprintf } from '~/locale'; +import query from '../graphql/queries/usage_count.query.graphql'; + +const noDataMessage = s__('UsageTrends|No data available.'); + +export default [ + { + loadChartError: sprintf( + s__('UsageTrends|Could not load the pipelines chart. Please refresh the page to try again.'), + ), + noDataMessage, + chartTitle: s__('UsageTrends|Pipelines'), + yAxisTitle: s__('UsageTrends|Items'), + xAxisTitle: s__('UsageTrends|Month'), + queries: [ + { + query, + title: s__('UsageTrends|Pipelines total'), + identifier: 'PIPELINES', + loadError: sprintf(s__('UsageTrends|There was an error fetching the total pipelines')), + }, + { + query, + title: s__('UsageTrends|Pipelines succeeded'), + identifier: 'PIPELINES_SUCCEEDED', + loadError: sprintf(s__('UsageTrends|There was an error fetching the successful pipelines')), + }, + { + query, + title: s__('UsageTrends|Pipelines failed'), + identifier: 'PIPELINES_FAILED', + loadError: sprintf(s__('UsageTrends|There was an error fetching the failed pipelines')), + }, + { + query, + title: s__('UsageTrends|Pipelines canceled'), + identifier: 'PIPELINES_CANCELED', + loadError: sprintf(s__('UsageTrends|There was an error fetching the cancelled pipelines')), + }, + { + query, + title: s__('UsageTrends|Pipelines skipped'), + identifier: 'PIPELINES_SKIPPED', + loadError: sprintf(s__('UsageTrends|There was an error fetching the skipped pipelines')), + }, + ], + }, + { + loadChartError: sprintf( + s__( + 'UsageTrends|Could not load the issues and merge requests chart. Please refresh the page to try again.', + ), + ), + noDataMessage, + chartTitle: s__('UsageTrends|Issues & Merge Requests'), + yAxisTitle: s__('UsageTrends|Items'), + xAxisTitle: s__('UsageTrends|Month'), + queries: [ + { + query, + title: __('Issues'), + identifier: 'ISSUES', + loadError: sprintf(s__('UsageTrends|There was an error fetching the issues')), + }, + { + query, + title: __('Merge requests'), + identifier: 'MERGE_REQUESTS', + loadError: sprintf(s__('UsageTrends|There was an error fetching the merge requests')), + }, + ], + }, +]; diff --git a/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/projects_and_groups_chart.vue index 3ffec90fb68..66aa939938e 100644 --- a/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/projects_and_groups_chart.vue @@ -1,11 +1,11 @@ <script> import { GlAlert } from '@gitlab/ui'; import { GlLineChart } from '@gitlab/ui/dist/charts'; +import * as Sentry from '@sentry/browser'; import produce from 'immer'; import { sortBy } from 'lodash'; import { formatDateAsMonth } from '~/lib/utils/datetime_utility'; import { s__, __ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import latestGroupsQuery from '../graphql/queries/groups.query.graphql'; import latestProjectsQuery from '../graphql/queries/projects.query.graphql'; @@ -113,14 +113,14 @@ export default { }, }, i18n: { - yAxisTitle: s__('InstanceStatistics|Total projects & groups'), + yAxisTitle: s__('UsageTrends|Total projects & groups'), xAxisTitle: __('Month'), loadChartError: s__( - 'InstanceStatistics|Could not load the projects and groups chart. Please refresh the page to try again.', + 'UsageTrends|Could not load the projects and groups chart. Please refresh the page to try again.', ), - loadProjectsDataError: s__('InstanceStatistics|There was an error while loading the projects'), - loadGroupsDataError: s__('InstanceStatistics|There was an error while loading the groups'), - noDataMessage: s__('InstanceStatistics|No data available.'), + loadProjectsDataError: s__('UsageTrends|There was an error while loading the projects'), + loadGroupsDataError: s__('UsageTrends|There was an error while loading the groups'), + noDataMessage: s__('UsageTrends|No data available.'), }, computed: { isLoadingGroups() { @@ -161,11 +161,11 @@ export default { chartData() { return [ { - name: s__('InstanceStatistics|Total projects'), + name: s__('UsageTrends|Total projects'), data: this.projectChartData, }, { - name: s__('InstanceStatistics|Total groups'), + name: s__('UsageTrends|Total groups'), data: this.groupChartData, }, ]; diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue index f3779ed62e9..9a0a4f61a74 100644 --- a/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue @@ -1,15 +1,15 @@ <script> +import * as Sentry from '@sentry/browser'; import MetricCard from '~/analytics/shared/components/metric_card.vue'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; import { s__ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; -import instanceStatisticsCountQuery from '../graphql/queries/instance_statistics_count.query.graphql'; +import usageTrendsCountQuery from '../graphql/queries/usage_trends_count.query.graphql'; const defaultPrecision = 0; export default { - name: 'InstanceCounts', + name: 'UsageCounts', components: { MetricCard, }, @@ -20,7 +20,7 @@ export default { }, apollo: { counts: { - query: instanceStatisticsCountQuery, + query: usageTrendsCountQuery, update(data) { return Object.entries(data).map(([key, obj]) => { const label = this.$options.i18n.labels[key]; @@ -42,14 +42,14 @@ export default { }, i18n: { labels: { - users: s__('InstanceStatistics|Users'), - projects: s__('InstanceStatistics|Projects'), - groups: s__('InstanceStatistics|Groups'), - issues: s__('InstanceStatistics|Issues'), - mergeRequests: s__('InstanceStatistics|Merge Requests'), - pipelines: s__('InstanceStatistics|Pipelines'), + users: s__('UsageTrends|Users'), + projects: s__('UsageTrends|Projects'), + groups: s__('UsageTrends|Groups'), + issues: s__('UsageTrends|Issues'), + mergeRequests: s__('UsageTrends|Merge Requests'), + pipelines: s__('UsageTrends|Pipelines'), }, - loadCountsError: s__('Could not load instance counts. Please refresh the page to try again.'), + loadCountsError: s__('Could not load usage counts. Please refresh the page to try again.'), }, }; </script> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue index e2defe0572d..8d7761694d1 100644 --- a/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue @@ -1,21 +1,21 @@ <script> import { GlAlert } from '@gitlab/ui'; import { GlLineChart } from '@gitlab/ui/dist/charts'; +import * as Sentry from '@sentry/browser'; import { some, every } from 'lodash'; import { differenceInMonths, formatDateAsMonth, getDayDifference, } from '~/lib/utils/datetime_utility'; -import * as Sentry from '~/sentry/wrapper'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import { TODAY, START_DATE } from '../constants'; import { getAverageByMonth, getEarliestDate, generateDataKeys } from '../utils'; -const QUERY_DATA_KEY = 'instanceStatisticsMeasurements'; +const QUERY_DATA_KEY = 'usageTrendsMeasurements'; export default { - name: 'InstanceStatisticsCountChart', + name: 'UsageTrendsCountChart', components: { GlLineChart, GlAlert, diff --git a/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue index 73940f028a1..09dfcddcb73 100644 --- a/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue @@ -1,11 +1,11 @@ <script> import { GlAlert } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import * as Sentry from '@sentry/browser'; import produce from 'immer'; import { sortBy } from 'lodash'; import { formatDateAsMonth } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import usersQuery from '../graphql/queries/users.query.graphql'; import { getAverageByMonth } from '../utils'; diff --git a/app/assets/javascripts/analytics/instance_statistics/constants.js b/app/assets/javascripts/analytics/usage_trends/constants.js index 846c0ef408b..846c0ef408b 100644 --- a/app/assets/javascripts/analytics/instance_statistics/constants.js +++ b/app/assets/javascripts/analytics/usage_trends/constants.js 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 new file mode 100644 index 00000000000..2bde5973600 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql @@ -0,0 +1,4 @@ +fragment Count on UsageTrendsMeasurement { + count + recordedAt +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/groups.query.graphql index ec56d91ffaa..b1249cc9480 100644 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/groups.query.graphql @@ -2,7 +2,7 @@ #import "../fragments/count.fragment.graphql" query getGroupsCount($first: Int, $after: String) { - groups: instanceStatisticsMeasurements(identifier: GROUPS, first: $first, after: $after) { + groups: usageTrendsMeasurements(identifier: GROUPS, first: $first, after: $after) { nodes { ...Count } diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/projects.query.graphql index 0845b703435..2e10b6cce3e 100644 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/projects.query.graphql @@ -2,7 +2,7 @@ #import "../fragments/count.fragment.graphql" query getProjectsCount($first: Int, $after: String) { - projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: $first, after: $after) { + projects: usageTrendsMeasurements(identifier: PROJECTS, first: $first, after: $after) { nodes { ...Count } diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql index dd22a16cd51..2a5546efb68 100644 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql @@ -2,7 +2,7 @@ #import "../fragments/count.fragment.graphql" query getCount($identifier: MeasurementIdentifier!, $first: Int, $after: String) { - instanceStatisticsMeasurements(identifier: $identifier, first: $first, after: $after) { + usageTrendsMeasurements(identifier: $identifier, first: $first, after: $after) { nodes { ...Count } diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql new file mode 100644 index 00000000000..8cadcfae380 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql @@ -0,0 +1,34 @@ +#import "../fragments/count.fragment.graphql" + +query getInstanceCounts { + projects: usageTrendsMeasurements(identifier: PROJECTS, first: 1) { + nodes { + ...Count + } + } + groups: usageTrendsMeasurements(identifier: GROUPS, first: 1) { + nodes { + ...Count + } + } + users: usageTrendsMeasurements(identifier: USERS, first: 1) { + nodes { + ...Count + } + } + issues: usageTrendsMeasurements(identifier: ISSUES, first: 1) { + nodes { + ...Count + } + } + mergeRequests: usageTrendsMeasurements(identifier: MERGE_REQUESTS, first: 1) { + nodes { + ...Count + } + } + pipelines: usageTrendsMeasurements(identifier: PIPELINES, first: 1) { + nodes { + ...Count + } + } +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql index 6235e36eb89..7c02ac49a42 100644 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql @@ -2,7 +2,7 @@ #import "../fragments/count.fragment.graphql" query getUsersCount($first: Int, $after: String) { - users: instanceStatisticsMeasurements(identifier: USERS, first: $first, after: $after) { + users: usageTrendsMeasurements(identifier: USERS, first: $first, after: $after) { nodes { ...Count } diff --git a/app/assets/javascripts/analytics/instance_statistics/index.js b/app/assets/javascripts/analytics/usage_trends/index.js index 0d7dcf6ace8..d1880b09f15 100644 --- a/app/assets/javascripts/analytics/instance_statistics/index.js +++ b/app/assets/javascripts/analytics/usage_trends/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import InstanceStatisticsApp from './components/app.vue'; +import UsageTrendsApp from './components/app.vue'; Vue.use(VueApollo); @@ -10,7 +10,7 @@ const apolloProvider = new VueApollo({ }); export default () => { - const el = document.getElementById('js-instance-statistics-app'); + const el = document.getElementById('js-usage-trends-app'); if (!el) return false; @@ -18,7 +18,7 @@ export default () => { el, apolloProvider, render(h) { - return h(InstanceStatisticsApp); + return h(UsageTrendsApp); }, }); }; diff --git a/app/assets/javascripts/analytics/instance_statistics/utils.js b/app/assets/javascripts/analytics/usage_trends/utils.js index 396962ffad6..91907877ed6 100644 --- a/app/assets/javascripts/analytics/instance_statistics/utils.js +++ b/app/assets/javascripts/analytics/usage_trends/utils.js @@ -41,8 +41,8 @@ export function getAverageByMonth(items = [], options = {}) { } /** - * Takes an array of instance counts and returns the last item in the list - * @param {Array} arr array of instance counts in the form { count: Number, recordedAt: date String } + * Takes an array of usage counts and returns the last item in the list + * @param {Array} arr array of usage counts in the form { count: Number, recordedAt: date String } * @return {String} the 'recordedAt' value of the earliest item */ export const getEarliestDate = (arr = []) => { @@ -54,7 +54,7 @@ export const getEarliestDate = (arr = []) => { * Takes an array of queries and produces an object with the query identifier as key * and a supplied defaultValue as its value * @param {Array} queries array of chart query configs, - * see ./analytics/instance_statistics/components/charts_config.js + * see ./analytics/usage_trends/components/charts_config.js * @param {any} defaultValue value to set each identifier to * @return {Object} key value pair of the form { queryIdentifier: defaultValue } */ diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index c7e6b98a934..a9bb2909c39 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -24,6 +24,7 @@ const Api = { projectPackagesPath: '/api/:version/projects/:id/packages', projectPackagePath: '/api/:version/projects/:id/packages/:package_id', groupProjectsPath: '/api/:version/groups/:id/projects.json', + groupSharePath: '/api/:version/groups/:id/share', projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', forkedProjectsPath: '/api/:version/projects/:id/forks', @@ -39,6 +40,7 @@ const Api = { projectRunnersPath: '/api/:version/projects/:id/runners', projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches', projectSearchPath: '/api/:version/projects/:id/search', + projectSharePath: '/api/:version/projects/:id/share', projectMilestonesPath: '/api/:version/projects/:id/milestones', projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid', mergeRequestsPath: '/api/:version/merge_requests', @@ -365,6 +367,16 @@ const Api = { }); }, + projectShareWithGroup(id, options = {}) { + const url = Api.buildUrl(Api.projectSharePath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, { + expires_at: options.expires_at, + group_access: options.group_access, + group_id: options.group_id, + }); + }, + projectMilestones(id, params = {}) { const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id)); @@ -426,6 +438,16 @@ const Api = { }); }, + groupShareWithGroup(id, options = {}) { + const url = Api.buildUrl(Api.groupSharePath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, { + expires_at: options.expires_at, + group_access: options.group_access, + group_id: options.group_id, + }); + }, + commit(id, sha, params = {}) { const url = Api.buildUrl(this.commitPath) .replace(':id', encodeURIComponent(id)) diff --git a/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue b/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue index 92f1cc8117a..d797469dd53 100644 --- a/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue +++ b/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue @@ -2,7 +2,6 @@ import { GlAlert, GlFormCheckbox, GlLink } from '@gitlab/ui'; import { __ } from '~/locale'; import UpdateKeepLatestArtifactProjectSetting from './graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql'; -import GetKeepLatestArtifactApplicationSetting from './graphql/queries/get_keep_latest_artifact_application_setting.query.graphql'; import GetKeepLatestArtifactProjectSetting from './graphql/queries/get_keep_latest_artifact_project_setting.query.graphql'; export default { @@ -14,7 +13,6 @@ export default { enabledHelpText: __( 'The latest artifacts created by jobs in the most recent successful pipeline will be stored.', ), - disabledHelpText: __('This feature is disabled at the instance level.'), helpLinkText: __('More information'), checkboxText: __('Keep artifacts from most recent successful jobs'), }, @@ -46,19 +44,12 @@ export default { this.reportError(this.$options.errors.fetchError); }, }, - projectSettingDisabled: { - query: GetKeepLatestArtifactApplicationSetting, - update(data) { - return !data.ciApplicationSettings?.keepLatestArtifact; - }, - }, }, data() { return { keepLatestArtifact: null, errorMessage: '', isAlertDismissed: false, - projectSettingDisabled: true, }; }, computed: { @@ -66,9 +57,7 @@ export default { return this.errorMessage && !this.isAlertDismissed; }, helpText() { - return this.projectSettingDisabled - ? this.$options.i18n.disabledHelpText - : this.$options.i18n.enabledHelpText; + return this.$options.i18n.enabledHelpText; }, }, methods: { @@ -106,10 +95,7 @@ export default { @dismiss="isAlertDismissed = true" >{{ errorMessage }}</gl-alert > - <gl-form-checkbox - v-model="keepLatestArtifact" - :disabled="projectSettingDisabled" - @change="updateSetting" + <gl-form-checkbox v-model="keepLatestArtifact" @change="updateSetting" ><strong class="gl-mr-3">{{ $options.i18n.checkboxText }}</strong> <gl-link :href="helpPagePath">{{ $options.i18n.helpLinkText }}</gl-link> <template v-if="!$apollo.loading" #help>{{ helpText }}</template> diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js index e72c5c90986..445602a8765 100644 --- a/app/assets/javascripts/blob/blob_file_dropzone.js +++ b/app/assets/javascripts/blob/blob_file_dropzone.js @@ -3,6 +3,7 @@ import Dropzone from 'dropzone'; import $ from 'jquery'; import { sprintf, __ } from '~/locale'; +import { trackUploadFileFormSubmitted } from '~/projects/upload_file_experiment'; import { HIDDEN_CLASS } from '../lib/utils/constants'; import csrf from '../lib/utils/csrf'; import { visitUrl } from '../lib/utils/url_utility'; @@ -83,6 +84,9 @@ export default class BlobFileDropzone { submitButton.on('click', (e) => { e.preventDefault(); e.stopPropagation(); + + trackUploadFileFormSubmitted(); + if (dropzone[0].dropzone.getQueuedFiles().length === 0) { // eslint-disable-next-line no-alert alert(__('Please select a file')); diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 173c82ef9b0..6d9b56b4bb8 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils'; +import { initUploadFileTrigger } from '~/projects/upload_file_experiment'; import Tracking from '~/tracking'; import BlobFileDropzone from '../blob/blob_file_dropzone'; import NewCommitForm from '../new_commit_form'; @@ -47,6 +48,7 @@ export const initUploadForm = () => { new NewCommitForm(uploadBlobForm); disableButtonIfEmptyField(uploadBlobForm.find('.js-commit-message'), '.btn-upload-file'); + initUploadFileTrigger(); } }; diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 13ad820477f..cf7e8cb94d1 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -36,11 +36,11 @@ export function formatIssue(issue) { } export function formatListIssues(listIssues) { - const issues = {}; - let listIssuesCount; + const boardItems = {}; + let listItemsCount; const listData = listIssues.nodes.reduce((map, list) => { - listIssuesCount = list.issues.count; + listItemsCount = list.issues.count; let sortedIssues = list.issues.edges.map((issueNode) => ({ ...issueNode.node, })); @@ -58,14 +58,14 @@ export function formatListIssues(listIssues) { assignees: i.assignees?.nodes || [], }; - issues[id] = listIssue; + boardItems[id] = listIssue; return id; }), }; }, {}); - return { listData, issues, listIssuesCount }; + return { listData, boardItems, listItemsCount }; } export function formatListsPageInfo(lists) { diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index e6009343626..a0fee1b064b 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,14 +1,11 @@ <script> -import sidebarEventHub from '~/sidebar/event_hub'; -import eventHub from '../eventhub'; -import boardsStore from '../stores/boards_store'; -import BoardCardLayout from './board_card_layout.vue'; -import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import IssueCardInner from './issue_card_inner.vue'; export default { - name: 'BoardsIssueCard', + name: 'BoardCard', components: { - BoardCardLayout: gon.features?.graphqlBoardLists ? BoardCardLayout : BoardCardLayoutDeprecated, + IssueCardInner, }, props: { list: { @@ -21,29 +18,41 @@ export default { default: () => ({}), required: false, }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + index: { + type: Number, + default: 0, + required: false, + }, }, - methods: { - // These are methods instead of computed's, because boardsStore is not reactive. + computed: { + ...mapState(['selectedBoardItems', 'activeId']), + ...mapGetters(['isSwimlanesOn']), isActive() { - return this.getActiveId() === this.issue.id; + return this.issue.id === this.activeId; }, - getActiveId() { - return boardsStore.detail?.issue?.id; + multiSelectVisible() { + return ( + !this.activeId && + this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1 + ); }, - showIssue({ isMultiSelect }) { - // If no issues are opened, close all sidebars first - if (!this.getActiveId()) { - sidebarEventHub.$emit('sidebar.closeAll'); - } - if (this.isActive()) { - eventHub.$emit('clearDetailIssue', isMultiSelect); + }, + methods: { + ...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']), + toggleIssue(e) { + // Don't do anything if this happened on a no trigger element + if (e.target.classList.contains('js-no-trigger')) return; - if (isMultiSelect) { - eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); - } + const isMultiSelect = e.ctrlKey || e.metaKey; + if (isMultiSelect) { + this.toggleBoardItemMultiSelection(this.issue); } else { - eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); - boardsStore.setListDetail(this.list); + this.toggleBoardItem({ boardItem: this.issue }); } }, }, @@ -51,12 +60,22 @@ export default { </script> <template> - <board-card-layout + <li data-qa-selector="board_card" - :issue="issue" - :list="list" - :is-active="isActive()" - v-bind="$attrs" - @show="showIssue" - /> + :class="{ + 'multi-select': multiSelectVisible, + 'user-can-drag': !disabled && issue.id, + 'is-disabled': disabled || !issue.id, + 'is-active': isActive, + }" + :index="index" + :data-issue-id="issue.id" + :data-issue-iid="issue.iid" + :data-issue-path="issue.referencePath" + data-testid="board_card" + class="board-card gl-p-5 gl-rounded-base" + @mouseup="toggleIssue($event)" + > + <issue-card-inner :list="list" :issue="issue" :update-filters="true" /> + </li> </template> diff --git a/app/assets/javascripts/boards/components/board_card_deprecated.vue b/app/assets/javascripts/boards/components/board_card_deprecated.vue new file mode 100644 index 00000000000..e12a2836f67 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_card_deprecated.vue @@ -0,0 +1,61 @@ +<script> +// This component is being replaced in favor of './board_card.vue' for GraphQL boards +import sidebarEventHub from '~/sidebar/event_hub'; +import eventHub from '../eventhub'; +import boardsStore from '../stores/boards_store'; +import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue'; + +export default { + components: { + BoardCardLayout: BoardCardLayoutDeprecated, + }, + props: { + list: { + type: Object, + default: () => ({}), + required: false, + }, + issue: { + type: Object, + default: () => ({}), + required: false, + }, + }, + methods: { + // These are methods instead of computed's, because boardsStore is not reactive. + isActive() { + return this.getActiveId() === this.issue.id; + }, + getActiveId() { + return boardsStore.detail?.issue?.id; + }, + showIssue({ isMultiSelect }) { + // If no issues are opened, close all sidebars first + if (!this.getActiveId()) { + sidebarEventHub.$emit('sidebar.closeAll'); + } + if (this.isActive()) { + eventHub.$emit('clearDetailIssue', isMultiSelect); + + if (isMultiSelect) { + eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); + } + } else { + eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); + boardsStore.setListDetail(this.list); + } + }, + }, +}; +</script> + +<template> + <board-card-layout + data-qa-selector="board_card" + :issue="issue" + :list="list" + :is-active="isActive()" + v-bind="$attrs" + @show="showIssue" + /> +</template> diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue deleted file mode 100644 index 5e3c3702519..00000000000 --- a/app/assets/javascripts/boards/components/board_card_layout.vue +++ /dev/null @@ -1,98 +0,0 @@ -<script> -import { mapActions, mapGetters, mapState } from 'vuex'; -import { ISSUABLE } from '~/boards/constants'; -import IssueCardInner from './issue_card_inner.vue'; - -export default { - name: 'BoardCardLayout', - components: { - IssueCardInner, - }, - props: { - list: { - type: Object, - default: () => ({}), - required: false, - }, - issue: { - type: Object, - default: () => ({}), - required: false, - }, - disabled: { - type: Boolean, - default: false, - required: false, - }, - index: { - type: Number, - default: 0, - required: false, - }, - isActive: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - showDetail: false, - }; - }, - computed: { - ...mapState(['selectedBoardItems']), - ...mapGetters(['isSwimlanesOn']), - multiSelectVisible() { - return this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1; - }, - }, - methods: { - ...mapActions(['setActiveId', 'toggleBoardItemMultiSelection']), - mouseDown() { - this.showDetail = true; - }, - mouseMove() { - this.showDetail = false; - }, - showIssue(e) { - // Don't do anything if this happened on a no trigger element - if (e.target.classList.contains('js-no-trigger')) return; - - const isMultiSelect = e.ctrlKey || e.metaKey; - - if (!isMultiSelect) { - this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE }); - } else { - this.toggleBoardItemMultiSelection(this.issue); - } - - if (this.showDetail || isMultiSelect) { - this.showDetail = false; - } - }, - }, -}; -</script> - -<template> - <li - :class="{ - 'multi-select': multiSelectVisible, - 'user-can-drag': !disabled && issue.id, - 'is-disabled': disabled || !issue.id, - 'is-active': isActive, - }" - :index="index" - :data-issue-id="issue.id" - :data-issue-iid="issue.iid" - :data-issue-path="issue.referencePath" - data-testid="board_card" - class="board-card gl-p-5 gl-rounded-base" - @mousedown="mouseDown" - @mousemove="mouseMove" - @mouseup="showIssue($event)" - > - <issue-card-inner :list="list" :issue="issue" :update-filters="true" /> - </li> -</template> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 41b9ee795eb..95a90d7ab11 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -32,12 +32,12 @@ export default { }, computed: { ...mapState(['filterParams', 'highlightedLists']), - ...mapGetters(['getIssuesByList']), + ...mapGetters(['getBoardItemsByList']), highlighted() { return this.highlightedLists.includes(this.list.id); }, - listIssues() { - return this.getIssuesByList(this.list.id); + listItems() { + return this.getBoardItemsByList(this.list.id); }, isListDraggable() { return isListDraggable(this.list); @@ -46,7 +46,7 @@ export default { watch: { filterParams: { handler() { - this.fetchIssuesForList({ listId: this.list.id }); + this.fetchItemsForList({ listId: this.list.id }); }, deep: true, immediate: true, @@ -63,7 +63,7 @@ export default { }, }, methods: { - ...mapActions(['fetchIssuesForList']), + ...mapActions(['fetchItemsForList']), }, }; </script> @@ -87,7 +87,7 @@ export default { <board-list ref="board-list" :disabled="disabled" - :issues="listIssues" + :board-items="listItems" :list="list" :can-admin-list="canAdminList" /> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 9b10e7d7db5..6b7e04df7a4 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -11,7 +11,10 @@ import BoardColumnDeprecated from './board_column_deprecated.vue'; export default { components: { - BoardColumn: gon.features?.graphqlBoardLists ? BoardColumn : BoardColumnDeprecated, + BoardColumn: + gon.features?.graphqlBoardLists || gon.features?.epicBoards + ? BoardColumn + : BoardColumnDeprecated, BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'), EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), GlAlert, @@ -33,10 +36,10 @@ export default { }, }, computed: { - ...mapState(['boardLists', 'error']), + ...mapState(['boardLists', 'error', 'isEpicBoard']), ...mapGetters(['isSwimlanesOn']), boardListsToUse() { - return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn + return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn || this.isEpicBoard ? sortBy([...Object.values(this.boardLists)], 'position') : this.lists; }, diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 7495b1163be..4bd5a530b8c 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -12,8 +12,8 @@ import BoardNewIssue from './board_new_issue.vue'; export default { name: 'BoardList', i18n: { - loadingIssues: __('Loading issues'), - loadingMoreissues: __('Loading more issues'), + loading: __('Loading'), + loadingMoreboardItems: __('Loading more'), showingAllIssues: __('Showing all issues'), }, components: { @@ -30,7 +30,7 @@ export default { type: Object, required: true, }, - issues: { + boardItems: { type: Array, required: true, }, @@ -51,11 +51,11 @@ export default { ...mapState(['pageInfoByListId', 'listsFlags']), paginatedIssueText() { return sprintf(__('Showing %{pageSize} of %{total} issues'), { - pageSize: this.issues.length, + pageSize: this.boardItems.length, total: this.list.issuesCount, }); }, - issuesSizeExceedsMax() { + boardItemsSizeExceedsMax() { return this.list.maxIssueCount > 0 && this.list.issuesCount > this.list.maxIssueCount; }, hasNextPage() { @@ -72,7 +72,7 @@ export default { return this.canAdminList ? this.$refs.list.$el : this.$refs.list; }, showingAllIssues() { - return this.issues.length === this.list.issuesCount; + return this.boardItems.length === this.list.issuesCount; }, treeRootWrapper() { return this.canAdminList ? Draggable : 'ul'; @@ -85,14 +85,14 @@ export default { tag: 'ul', 'ghost-class': 'board-card-drag-active', 'data-list-id': this.list.id, - value: this.issues, + value: this.boardItems, }; return this.canAdminList ? options : {}; }, }, watch: { - issues() { + boardItems() { this.$nextTick(() => { this.showCount = this.scrollHeight() > Math.ceil(this.listHeight()); }); @@ -112,7 +112,7 @@ export default { this.listRef.removeEventListener('scroll', this.onScroll); }, methods: { - ...mapActions(['fetchIssuesForList', 'moveIssue']), + ...mapActions(['fetchItemsForList', 'moveIssue']), listHeight() { return this.listRef.getBoundingClientRect().height; }, @@ -126,7 +126,7 @@ export default { this.listRef.scrollTop = 0; }, loadNextPage() { - this.fetchIssuesForList({ listId: this.list.id, fetchNext: true }); + this.fetchItemsForList({ listId: this.list.id, fetchNext: true }); }, toggleForm() { this.showIssueForm = !this.showIssueForm; @@ -201,7 +201,7 @@ export default { <div v-if="loading" class="gl-mt-4 gl-text-center" - :aria-label="$options.i18n.loadingIssues" + :aria-label="$options.i18n.loading" data-testid="board_list_loading" > <gl-loading-icon /> @@ -214,23 +214,27 @@ export default { v-bind="treeRootOptions" :data-board="list.id" :data-board-type="list.listType" - :class="{ 'bg-danger-100': issuesSizeExceedsMax }" + :class="{ 'bg-danger-100': boardItemsSizeExceedsMax }" class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list" data-testid="tree-root-wrapper" @start="handleDragOnStart" @end="handleDragOnEnd" > <board-card - v-for="(issue, index) in issues" + v-for="(item, index) in boardItems" ref="issue" - :key="issue.id" + :key="item.id" :index="index" :list="list" - :issue="issue" + :issue="item" :disabled="disabled" /> <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> - <gl-loading-icon v-if="loadingMore" :label="$options.i18n.loadingMoreissues" /> + <gl-loading-icon + v-if="loadingMore" + :label="$options.i18n.loadingMoreboardItems" + data-testid="count-loading-icon" + /> <span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span> <span v-else>{{ paginatedIssueText }}</span> </li> diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue index 9b4961d362d..d59fbcc1b31 100644 --- a/app/assets/javascripts/boards/components/board_list_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue @@ -11,7 +11,7 @@ import { sortableEnd, } from '../mixins/sortable_default_options'; import boardsStore from '../stores/boards_store'; -import boardCard from './board_card.vue'; +import boardCard from './board_card_deprecated.vue'; import boardNewIssue from './board_new_issue_deprecated.vue'; // This component is being replaced in favor of './board_list.vue' for GraphQL boards diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 2a064aaa885..3bd94631396 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -158,6 +158,18 @@ export default { cancel() { this.showPage(''); }, + boardUpdate(data) { + if (!data?.[this.parentType]) { + return []; + } + return data[this.parentType].boards.edges.map(({ node }) => ({ + id: getIdFromGraphQLId(node.id), + name: node.name, + })); + }, + boardQuery() { + return this.groupId ? groupQuery : projectQuery; + }, loadBoards(toggleDropdown = true) { if (toggleDropdown && this.boards.length > 0) { return; @@ -167,21 +179,14 @@ export default { variables() { return { fullPath: this.fullPath }; }, - query() { - return this.groupId ? groupQuery : projectQuery; - }, + query: this.boardQuery, loadingKey: 'loadingBoards', - update(data) { - if (!data?.[this.parentType]) { - return []; - } - return data[this.parentType].boards.edges.map(({ node }) => ({ - id: getIdFromGraphQLId(node.id), - name: node.name, - })); - }, + update: this.boardUpdate, }); + this.loadRecentBoards(); + }, + loadRecentBoards() { this.loadingRecentBoards = true; // Follow up to fetch recent boards using GraphQL // https://gitlab.com/gitlab-org/gitlab/-/issues/300985 diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue new file mode 100644 index 00000000000..7ec99e51f5b --- /dev/null +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -0,0 +1,64 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { formType } from '~/boards/constants'; +import eventHub from '~/boards/eventhub'; +import { s__, __ } from '~/locale'; + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModalDirective, + }, + props: { + boardsStore: { + type: Object, + required: true, + }, + canAdminList: { + type: Boolean, + required: true, + }, + hasScope: { + type: Boolean, + required: true, + }, + }, + data() { + return { + state: this.boardsStore.state, + }; + }, + computed: { + buttonText() { + return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope'); + }, + tooltipTitle() { + return this.hasScope ? __("This board's scope is reduced") : ''; + }, + }, + methods: { + showPage() { + eventHub.$emit('showBoardModal', formType.edit); + return this.boardsStore.showPage(formType.edit); + }, + }, +}; +</script> + +<template> + <div class="gl-ml-3 gl-display-flex gl-align-items-center"> + <gl-button + v-gl-modal-directive="'board-config-modal'" + v-gl-tooltip + :title="tooltipTitle" + :class="{ 'dot-highlight': hasScope }" + data-qa-selector="boards_config_button" + @click.prevent="showPage" + > + {{ buttonText }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js index 2d1ec238274..7f327c5764d 100644 --- a/app/assets/javascripts/boards/config_toggle.js +++ b/app/assets/javascripts/boards/config_toggle.js @@ -1 +1,24 @@ -export default () => {}; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import ConfigToggle from './components/config_toggle.vue'; + +export default (boardsStore) => { + const el = document.querySelector('.js-board-config'); + + if (!el) { + return; + } + + gl.boardConfigToggle = new Vue({ + el, + render(h) { + return h(ConfigToggle, { + props: { + boardsStore, + canAdminList: parseBoolean(el.dataset.canAdminList), + hasScope: parseBoolean(el.dataset.hasScope), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 859295318ed..f9dfa60a59b 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -6,7 +6,6 @@ import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar'; import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown'; -import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; import { setWeightFetchingState, setEpicFetchingState, @@ -40,6 +39,7 @@ import { } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; +import boardConfigToggle from './config_toggle'; import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; Vue.use(VueApollo); diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index fa58af24ba2..7bec0901ed2 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { mapGetters } from 'vuex'; -import BoardsSelector from '~/boards/components/boards_selector.vue'; +import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue'; import BoardsSelectorDeprecated from '~/boards/components/boards_selector_deprecated.vue'; import store from '~/boards/stores'; import createDefaultClient from '~/lib/graphql'; @@ -51,7 +51,7 @@ export default (params = {}) => { ...mapGetters(['shouldUseGraphQL']), }, render(createElement) { - if (this.shouldUseGraphQL) { + if (this.shouldUseGraphQL || params.isEpicBoard) { return createElement(BoardsSelector, { props: this.boardsSelectorProps, }); diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index a7cf1e9e647..b8d84899782 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,6 +1,12 @@ import { pick } from 'lodash'; import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; -import { BoardType, ListType, inactiveId, flashAnimationDuration } from '~/boards/constants'; +import { + BoardType, + ListType, + inactiveId, + flashAnimationDuration, + ISSUABLE, +} from '~/boards/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils'; @@ -79,7 +85,11 @@ export default { } }, - fetchLists: ({ commit, state, dispatch }) => { + fetchLists: ({ dispatch }) => { + dispatch('fetchIssueLists'); + }, + + fetchIssueLists: ({ commit, state, dispatch }) => { const { boardType, filterParams, fullPath, boardId } = state; const variables = { @@ -253,8 +263,8 @@ export default { }); }, - fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => { - commit(types.REQUEST_ISSUES_FOR_LIST, { listId, fetchNext }); + fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => { + commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext }); const { fullPath, boardId, boardType, filterParams } = state; @@ -279,11 +289,11 @@ export default { }) .then(({ data }) => { const { lists } = data[boardType]?.board; - const listIssues = formatListIssues(lists); + const listItems = formatListIssues(lists); const listPageInfo = formatListsPageInfo(lists); - commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listPageInfo, listId }); + commit(types.RECEIVE_ITEMS_FOR_LIST_SUCCESS, { listItems, listPageInfo, listId }); }) - .catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId)); + .catch(() => commit(types.RECEIVE_ITEMS_FOR_LIST_FAILURE, listId)); }, resetIssues: ({ commit }) => { @@ -294,8 +304,8 @@ export default { { state, commit }, { issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId }, ) => { - const originalIssue = state.issues[issueId]; - const fromList = state.issuesByListId[fromListId]; + const originalIssue = state.boardItems[issueId]; + const fromList = state.boardItemsByListId[fromListId]; const originalIndex = fromList.indexOf(Number(issueId)); commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId }); @@ -532,10 +542,17 @@ export default { commit(types.SET_SELECTED_PROJECT, project); }, - toggleBoardItemMultiSelection: ({ commit, state }, boardItem) => { + toggleBoardItemMultiSelection: ({ commit, state, dispatch, getters }, boardItem) => { const { selectedBoardItems } = state; const index = selectedBoardItems.indexOf(boardItem); + // If user already selected an item (activeIssue) without using mult-select, + // include that item in the selection and unset state.ActiveId to hide the sidebar. + if (getters.activeIssue) { + commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeIssue); + dispatch('unsetActiveId'); + } + if (index === -1) { commit(types.ADD_BOARD_ITEM_TO_SELECTION, boardItem); } else { @@ -547,6 +564,20 @@ export default { commit(types.SET_ADD_COLUMN_FORM_VISIBLE, visible); }, + resetBoardItemMultiSelection: ({ commit }) => { + commit(types.RESET_BOARD_ITEM_SELECTION); + }, + + toggleBoardItem: ({ state, dispatch }, { boardItem, sidebarType = ISSUABLE }) => { + dispatch('resetBoardItemMultiSelection'); + + if (boardItem.id === state.activeId) { + dispatch('unsetActiveId'); + } else { + dispatch('setActiveId', { id: boardItem.id, sidebarType }); + } + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index cab97088bc6..308dbd0f1b0 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -4,17 +4,17 @@ import { inactiveId } from '../constants'; export default { isSidebarOpen: (state) => state.activeId !== inactiveId, isSwimlanesOn: () => false, - getIssueById: (state) => (id) => { - return state.issues[id] || {}; + getBoardItemById: (state) => (id) => { + return state.boardItems[id] || {}; }, - getIssuesByList: (state, getters) => (listId) => { - const listIssueIds = state.issuesByListId[listId] || []; - return listIssueIds.map((id) => getters.getIssueById(id)); + getBoardItemsByList: (state, getters) => (listId) => { + const listItemsIds = state.boardItemsByListId[listId] || []; + return listItemsIds.map((id) => getters.getBoardItemById(id)); }, activeIssue: (state) => { - return state.issues[state.activeId] || {}; + return state.boardItems[state.activeId] || {}; }, groupPathForActiveIssue: (_, getters) => { diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index a89e961ae2d..4b43cca9675 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -14,9 +14,9 @@ export const MOVE_LIST = 'MOVE_LIST'; export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; export const REMOVE_LIST = 'REMOVE_LIST'; export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; -export const REQUEST_ISSUES_FOR_LIST = 'REQUEST_ISSUES_FOR_LIST'; -export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE'; -export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS'; +export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST'; +export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE'; +export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS'; export const CREATE_ISSUE_FAILURE = 'CREATE_ISSUE_FAILURE'; export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE'; export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS'; @@ -45,3 +45,4 @@ export const REMOVE_BOARD_ITEM_FROM_SELECTION = 'REMOVE_BOARD_ITEM_FROM_SELECTIO export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE'; export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS'; export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS'; +export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 79c98c3d90c..8246ed8eb09 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -11,13 +11,13 @@ const notImplemented = () => { }; export const removeIssueFromList = ({ state, listId, issueId }) => { - Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId)); + Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], issueId)); const list = state.boardLists[listId]; Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount - 1 }); }; export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => { - const listIssues = state.issuesByListId[listId]; + const listIssues = state.boardItemsByListId[listId]; let newIndex = atIndex || 0; if (moveBeforeId) { newIndex = listIssues.indexOf(moveBeforeId) + 1; @@ -25,19 +25,20 @@ export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfter newIndex = listIssues.indexOf(moveAfterId); } listIssues.splice(newIndex, 0, issueId); - Vue.set(state.issuesByListId, listId, listIssues); + Vue.set(state.boardItemsByListId, listId, listIssues); const list = state.boardLists[listId]; Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + 1 }); }; export default { [mutationTypes.SET_INITIAL_BOARD_DATA](state, data) { - const { boardType, disabled, boardId, fullPath, boardConfig } = data; + const { boardType, disabled, boardId, fullPath, boardConfig, isEpicBoard } = data; state.boardId = boardId; state.fullPath = fullPath; state.boardType = boardType; state.disabled = disabled; state.boardConfig = boardConfig; + state.isEpicBoard = isEpicBoard; }, [mutationTypes.RECEIVE_BOARD_LISTS_SUCCESS]: (state, lists) => { @@ -103,26 +104,23 @@ export default { state.boardLists = listsBackup; }, - [mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => { + [mutationTypes.REQUEST_ITEMS_FOR_LIST]: (state, { listId, fetchNext }) => { Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true }); }, - [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: ( - state, - { listIssues, listPageInfo, listId }, - ) => { - const { listData, issues } = listIssues; - Vue.set(state, 'issues', { ...state.issues, ...issues }); + [mutationTypes.RECEIVE_ITEMS_FOR_LIST_SUCCESS]: (state, { listItems, listPageInfo, listId }) => { + const { listData, boardItems } = listItems; + Vue.set(state, 'boardItems', { ...state.boardItems, ...boardItems }); Vue.set( - state.issuesByListId, + state.boardItemsByListId, listId, - union(state.issuesByListId[listId] || [], listData[listId]), + union(state.boardItemsByListId[listId] || [], listData[listId]), ); Vue.set(state.pageInfoByListId, listId, listPageInfo[listId]); Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false }); }, - [mutationTypes.RECEIVE_ISSUES_FOR_LIST_FAILURE]: (state, listId) => { + [mutationTypes.RECEIVE_ITEMS_FOR_LIST_FAILURE]: (state, listId) => { state.error = s__( 'Boards|An error occurred while fetching the board issues. Please reload the page.', ); @@ -130,18 +128,18 @@ export default { }, [mutationTypes.RESET_ISSUES]: (state) => { - Object.keys(state.issuesByListId).forEach((listId) => { - Vue.set(state.issuesByListId, listId, []); + Object.keys(state.boardItemsByListId).forEach((listId) => { + Vue.set(state.boardItemsByListId, listId, []); }); }, [mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => { - if (!state.issues[issueId]) { + if (!state.boardItems[issueId]) { /* eslint-disable-next-line @gitlab/require-i18n-strings */ throw new Error('No issue found.'); } - Vue.set(state.issues[issueId], prop, value); + Vue.set(state.boardItems[issueId], prop, value); }, [mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) { @@ -168,7 +166,7 @@ export default { const toList = state.boardLists[toListId]; const issue = moveIssueListHelper(originalIssue, fromList, toList); - Vue.set(state.issues, issue.id, issue); + Vue.set(state.boardItems, issue.id, issue); removeIssueFromList({ state, listId: fromListId, issueId: issue.id }); addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId }); @@ -176,7 +174,7 @@ export default { [mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => { const issueId = getIdFromGraphQLId(issue.id); - Vue.set(state.issues, issueId, formatIssue({ ...issue, id: issueId })); + Vue.set(state.boardItems, issueId, formatIssue({ ...issue, id: issueId })); }, [mutationTypes.MOVE_ISSUE_FAILURE]: ( @@ -184,7 +182,7 @@ export default { { originalIssue, fromListId, toListId, originalIndex }, ) => { state.error = s__('Boards|An error occurred while moving the issue. Please try again.'); - Vue.set(state.issues, originalIssue.id, originalIssue); + Vue.set(state.boardItems, originalIssue.id, originalIssue); removeIssueFromList({ state, listId: toListId, issueId: originalIssue.id }); addIssueToList({ state, @@ -217,7 +215,7 @@ export default { issueId: issue.id, atIndex: position, }); - Vue.set(state.issues, issue.id, issue); + Vue.set(state.boardItems, issue.id, issue); }, [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => { @@ -227,7 +225,7 @@ export default { [mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => { removeIssueFromList({ state, listId: list.id, issueId: issue.id }); - Vue.delete(state.issues, issue.id); + Vue.delete(state.boardItems, issue.id); }, [mutationTypes.SET_CURRENT_PAGE]: () => { @@ -282,4 +280,8 @@ export default { [mutationTypes.REMOVE_LIST_FROM_HIGHLIGHTED_LISTS]: (state, listId) => { state.highlightedLists = state.highlightedLists.filter((id) => id !== listId); }, + + [mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => { + state.selectedBoardItems = []; + }, }; diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 91544d6c9c5..85d92589d30 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -2,16 +2,17 @@ import { inactiveId } from '~/boards/constants'; export default () => ({ boardType: null, + fullPath: null, disabled: false, isShowingLabels: true, activeId: inactiveId, sidebarType: '', boardLists: {}, listsFlags: {}, - issuesByListId: {}, + boardItemsByListId: {}, isSettingAssignees: false, pageInfoByListId: {}, - issues: {}, + boardItems: {}, filterParams: {}, boardConfig: {}, labels: [], diff --git a/app/assets/javascripts/clone_panel.js b/app/assets/javascripts/clone_panel.js index c9fae8f17a4..ec831a77bde 100644 --- a/app/assets/javascripts/clone_panel.js +++ b/app/assets/javascripts/clone_panel.js @@ -15,7 +15,6 @@ export default function initClonePanel() { } $('a', $cloneOptions).on('click', (e) => { - e.preventDefault(); const $this = $(e.currentTarget); const url = $this.attr('href'); if (url && (url.startsWith('vscode://') || url.startsWith('xcode://'))) { diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index 45d3e0cbc23..40a86a1e58c 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -1,9 +1,9 @@ +import * as Sentry from '@sentry/browser'; import { deprecatedCreateFlash as flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; import { MAX_REQUESTS } from '../constants'; import * as types from './mutation_types'; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js index 920ffde3e32..2e050c066f1 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -31,10 +31,8 @@ export default () => { return createElement(CommitPipelinesTable, { props: { endpoint: pipelineTableViewEl.dataset.endpoint, - helpPagePath: pipelineTableViewEl.dataset.helpPagePath, emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath, errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath, - autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath, }, }); }, diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 787152d00ef..81d96333f7a 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -25,14 +25,6 @@ export default { type: String, required: true, }, - helpPagePath: { - type: String, - required: true, - }, - autoDevopsHelpPath: { - type: String, - required: true, - }, errorStateSvgPath: { type: String, required: true, @@ -212,7 +204,6 @@ export default { <pipelines-table-component :pipelines="state.pipelines" :update-graph-dropdown="updateGraphDropdown" - :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" > <template #table-header-actions> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js index 0f0db2090c1..1c698cc2796 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js @@ -1,8 +1,9 @@ export const DEFAULT_REGION = 'us-east-2'; export const KUBERNETES_VERSIONS = [ - { name: '1.14', value: '1.14' }, { name: '1.15', value: '1.15' }, - { name: '1.16', value: '1.16', default: true }, + { name: '1.16', value: '1.16' }, { name: '1.17', value: '1.17' }, + { name: '1.18', value: '1.18' }, + { name: '1.19', value: '1.19', default: true }, ]; diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js index 162491312a8..a1dd12ff769 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js @@ -29,6 +29,26 @@ const FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-fil const NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter'; +let mouseEventListenersAdded = false; +let mousedownTarget = null; +let mouseupTarget = null; + +function addGlobalMouseEventListeners() { + // Remember mousedown and mouseup locations. + // Required in the `hide.bs.dropdown` listener for + // dropdown close prevention in some cases. + document.addEventListener('mousedown', ({ target }) => { + mousedownTarget = target; + }); + document.addEventListener('mouseup', ({ target }) => { + mouseupTarget = target; + }); + document.addEventListener('click', () => { + mousedownTarget = null; + mouseupTarget = null; + }); +} + export class GitLabDropdown { constructor(el1, options) { let selector; @@ -36,9 +56,14 @@ export class GitLabDropdown { this.el = el1; this.options = options; this.updateLabel = this.updateLabel.bind(this); - this.hidden = this.hidden.bind(this); this.opened = this.opened.bind(this); + this.hide = this.hide.bind(this); + this.hidden = this.hidden.bind(this); this.shouldPropagate = this.shouldPropagate.bind(this); + if (!mouseEventListenersAdded) { + addGlobalMouseEventListeners(); + mouseEventListenersAdded = true; + } self = this; selector = $(this.el).data('target'); this.dropdown = selector != null ? $(selector) : $(this.el).parent(); @@ -132,6 +157,7 @@ export class GitLabDropdown { } // Event listeners this.dropdown.on('shown.bs.dropdown', this.opened); + this.dropdown.on('hide.bs.dropdown', this.hide); this.dropdown.on('hidden.bs.dropdown', this.hidden); $(this.el).on('update.label', this.updateLabel); this.dropdown.on('click', '.dropdown-menu, .dropdown-menu-close', this.shouldPropagate); @@ -334,6 +360,21 @@ export class GitLabDropdown { $menu.css('bottom', '100%'); } + hide(e) { + // Prevent dropdowns with a search from being closed when the + // mousedown event happened inside the dropdown box and only + // the mouseup event did not. + if (this.options.search && mousedownTarget) { + const isIn = (element, $possibleContainer) => Boolean($possibleContainer.has(element).length); + const $menu = this.dropdown.find('.dropdown-menu'); + const mousedownInsideDropdown = isIn(mousedownTarget, $menu); + const mouseupOutsideDropdown = !isIn(mouseupTarget, $menu); + if (mousedownInsideDropdown && mouseupOutsideDropdown) { + e.preventDefault(); + } + } + } + hidden(e) { this.resetRows(); this.removeArrowKeyEvent(); diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 33f0aa00cad..41b123c7cb1 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -257,8 +257,8 @@ export default { <reply-placeholder v-if="!isFormVisible" class="qa-discussion-reply" - :button-text="__('Reply...')" - @onClick="showForm" + :placeholder-text="__('Reply…')" + @focus="showForm" /> <apollo-mutation v-else diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue index 9027d0c8aa4..3766c125325 100644 --- a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue +++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue @@ -35,8 +35,9 @@ export default { <slot v-if="hasForm" name="form"></slot> <template v-else-if="renderReplyPlaceholder"> <reply-placeholder - :button-text="__('Start a new discussion...')" - @onClick="$emit('showNewDiscussionForm')" + :placeholder-text="__('Start a new discussion…')" + :label-text="__('New discussion')" + @focus="$emit('showNewDiscussionForm')" /> </template> </template> diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 87b4f33c216..b37a75eb2a3 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -283,7 +283,7 @@ export function addContextLines(options) { * Trims the first char of the `richText` property when it's either a space or a diff symbol. * @param {Object} line * @returns {Object} - * @deprecated + * @deprecated Use `line.rich_text = line.rich_text ? line.rich_text.replace(/^[+ -]/, '') : undefined;` instead!. For more information, see https://gitlab.com/gitlab-org/gitlab/-/issues/299329 */ export function trimFirstCharOfLineContent(line = {}) { // eslint-disable-next-line no-param-reassign diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index d9e6a6c13e2..c991316dda2 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -16,6 +16,9 @@ export const EDITOR_READY_EVENT = 'editor-ready'; export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor'; export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor'; +export const EDITOR_CODE_INSTANCE_FN = 'createInstance'; +export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance'; + // // EXTENSIONS' CONSTANTS // diff --git a/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js b/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js new file mode 100644 index 00000000000..83b0386d470 --- /dev/null +++ b/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js @@ -0,0 +1,164 @@ +import { debounce } from 'lodash'; +import { KeyCode, KeyMod, Range } from 'monaco-editor'; +import { EDITOR_TYPE_DIFF } from '~/editor/constants'; +import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base'; +import Disposable from '~/ide/lib/common/disposable'; +import { editorOptions } from '~/ide/lib/editor_options'; +import keymap from '~/ide/lib/keymap.json'; + +const isDiffEditorType = (instance) => { + return instance.getEditorType() === EDITOR_TYPE_DIFF; +}; + +export const UPDATE_DIMENSIONS_DELAY = 200; + +export class EditorWebIdeExtension extends EditorLiteExtension { + constructor({ instance, modelManager, ...options } = {}) { + super({ + instance, + ...options, + modelManager, + disposable: new Disposable(), + debouncedUpdate: debounce(() => { + instance.updateDimensions(); + }, UPDATE_DIMENSIONS_DELAY), + }); + + window.addEventListener('resize', instance.debouncedUpdate, false); + + instance.onDidDispose(() => { + window.removeEventListener('resize', instance.debouncedUpdate); + + // catch any potential errors with disposing the error + // this is mainly for tests caused by elements not existing + try { + instance.disposable.dispose(); + } catch (e) { + if (process.env.NODE_ENV !== 'test') { + // eslint-disable-next-line no-console + console.error(e); + } + } + }); + + EditorWebIdeExtension.addActions(instance); + } + + static addActions(instance) { + const { store } = instance; + const getKeyCode = (key) => { + const monacoKeyMod = key.indexOf('KEY_') === 0; + + return monacoKeyMod ? KeyCode[key] : KeyMod[key]; + }; + + keymap.forEach((command) => { + const { bindings, id, label, action } = command; + + const keybindings = bindings.map((binding) => { + const keys = binding.split('+'); + + // eslint-disable-next-line no-bitwise + return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]); + }); + + instance.addAction({ + id, + label, + keybindings, + run() { + store.dispatch(action.name, action.params); + return null; + }, + }); + }); + } + + createModel(file, head = null) { + return this.modelManager.addModel(file, head); + } + + attachModel(model) { + if (isDiffEditorType(this)) { + this.setModel({ + original: model.getOriginalModel(), + modified: model.getModel(), + }); + + return; + } + + this.setModel(model.getModel()); + + this.updateOptions( + editorOptions.reduce((acc, obj) => { + Object.keys(obj).forEach((key) => { + Object.assign(acc, { + [key]: obj[key](model), + }); + }); + return acc; + }, {}), + ); + } + + attachMergeRequestModel(model) { + this.setModel({ + original: model.getBaseModel(), + modified: model.getModel(), + }); + } + + updateDimensions() { + this.layout(); + this.updateDiffView(); + } + + setPos({ lineNumber, column }) { + this.revealPositionInCenter({ + lineNumber, + column, + }); + this.setPosition({ + lineNumber, + column, + }); + } + + onPositionChange(cb) { + if (!this.onDidChangeCursorPosition) { + return; + } + + this.disposable.add(this.onDidChangeCursorPosition((e) => cb(this, e))); + } + + updateDiffView() { + if (!isDiffEditorType(this)) { + return; + } + + this.updateOptions({ + renderSideBySide: EditorWebIdeExtension.renderSideBySide(this.getDomNode()), + }); + } + + replaceSelectedText(text) { + let selection = this.getSelection(); + const range = new Range( + selection.startLineNumber, + selection.startColumn, + selection.endLineNumber, + selection.endColumn, + ); + + this.executeEdits('', [{ range, text }]); + + selection = this.getSelection(); + this.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn }); + } + + static renderSideBySide(domElement) { + return domElement.offsetWidth >= 700; + } +} diff --git a/app/assets/javascripts/experiment_tracking.js b/app/assets/javascripts/experiment_tracking.js new file mode 100644 index 00000000000..4611af3f857 --- /dev/null +++ b/app/assets/javascripts/experiment_tracking.js @@ -0,0 +1,25 @@ +import { get } from 'lodash'; +import Tracking from '~/tracking'; + +const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0'; + +export default class ExperimentTracking { + constructor(experimentName, { label } = {}) { + this.label = label; + this.experimentData = get(window, ['gon', 'global', 'experiment', experimentName]); + } + + event(action) { + if (!this.experimentData) { + return false; + } + + return Tracking.event(document.body.dataset.page, action, { + label: this.label, + context: { + schema: TRACKING_CONTEXT_SCHEMA, + data: this.experimentData, + }, + }); + } +} diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue index b1e60066e11..e7f4b51c964 100644 --- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue +++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue @@ -86,6 +86,8 @@ export default { data-track-event="click_button" data-track-label="feature_flag_toggle" class="gl-mr-4" + :label="__('Feature flag status')" + label-position="hidden" @change="toggleActive" /> <h3 class="page-title gl-m-0">{{ title }}</h3> diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index d14af53746e..d26a6bc5f6b 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -1,5 +1,5 @@ +import * as Sentry from '@sentry/browser'; import { escape } from 'lodash'; -import * as Sentry from '~/sentry/wrapper'; import { spriteIcon } from './lib/utils/common_utils'; const FLASH_TYPES = { diff --git a/app/assets/javascripts/groups/components/invite_members_banner.vue b/app/assets/javascripts/groups/components/invite_members_banner.vue index 81c5e3ce85d..747cea6a46e 100644 --- a/app/assets/javascripts/groups/components/invite_members_banner.vue +++ b/app/assets/javascripts/groups/components/invite_members_banner.vue @@ -35,7 +35,9 @@ export default { this.track(this.$options.dismissEvent); }, trackOnShow() { - if (!this.isDismissed) this.track(this.$options.displayEvent); + this.$nextTick(() => { + if (!this.isDismissed) this.track(this.$options.displayEvent); + }); }, addTrackingAttributesToButton() { if (this.$refs.banner === undefined) return; diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue index 1ae7cf9339d..62e93335a20 100644 --- a/app/assets/javascripts/ide/components/branches/search_list.vue +++ b/app/assets/javascripts/ide/components/branches/search_list.vue @@ -57,7 +57,10 @@ export default { <template> <div> - <label class="dropdown-input pt-3 pb-3 mb-0 border-bottom block position-relative" @click.stop> + <label + class="dropdown-input gl-pt-3 gl-pb-5 gl-mb-0 gl-border-b-1 gl-border-b-solid gl-display-block" + @click.stop + > <input ref="searchInput" v-model="search" diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue index bd4c4f18141..0803925104d 100644 --- a/app/assets/javascripts/ide/components/file_templates/bar.vue +++ b/app/assets/javascripts/ide/components/file_templates/bar.vue @@ -49,7 +49,9 @@ export default { </script> <template> - <div class="d-flex align-items-center ide-file-templates qa-file-templates-bar"> + <div + class="d-flex align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1" + > <strong class="gl-mr-3"> {{ __('File templates') }} </strong> <dropdown :data="templateTypes" diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index 680e8841a1f..7cb6d4d3dac 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -75,7 +75,10 @@ export default { <template> <div> - <label class="dropdown-input pt-3 pb-3 mb-0 border-bottom block" @click.stop> + <label + class="dropdown-input gl-pt-3 gl-pb-5 gl-mb-0 gl-border-b-1 gl-border-b-solid gl-display-block" + @click.stop + > <tokened-input v-model="search" :tokens="searchTokens" diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue index 62bb4841760..98f0504298b 100644 --- a/app/assets/javascripts/ide/components/nav_form.vue +++ b/app/assets/javascripts/ide/components/nav_form.vue @@ -1,13 +1,12 @@ <script> -import Tab from '~/vue_shared/components/tabs/tab.vue'; -import Tabs from '~/vue_shared/components/tabs/tabs'; +import { GlTab, GlTabs } from '@gitlab/ui'; import BranchesSearchList from './branches/search_list.vue'; import MergeRequestSearchList from './merge_requests/list.vue'; export default { components: { - Tabs, - Tab, + GlTab, + GlTabs, BranchesSearchList, MergeRequestSearchList, }, @@ -23,20 +22,14 @@ export default { <template> <div class="ide-nav-form p-0"> - <tabs v-if="showMergeRequests" stop-propagation> - <tab active> - <template #title> - {{ __('Branches') }} - </template> + <gl-tabs v-if="showMergeRequests"> + <gl-tab :title="__('Branches')"> <branches-search-list /> - </tab> - <tab> - <template #title> - {{ __('Merge Requests') }} - </template> + </gl-tab> + <gl-tab :title="__('Merge Requests')"> <merge-request-search-list /> - </tab> - </tabs> + </gl-tab> + </gl-tabs> <branches-search-list v-else /> </div> </template> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 2526db0cd7b..907ac496982 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -32,7 +32,7 @@ export default { SafeHtml, }, computed: { - ...mapState(['pipelinesEmptyStateSvgPath', 'links']), + ...mapState(['pipelinesEmptyStateSvgPath']), ...mapGetters(['currentProject']), ...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']), ...mapState('pipelines', [ @@ -85,7 +85,6 @@ export default { </header> <empty-state v-if="!latestPipeline" - :help-page-path="links.ciHelpPagePath" :empty-state-svg-path="pipelinesEmptyStateSvgPath" :can-set-ci="true" class="mb-auto mt-auto" diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 690060f5cb0..b57dcd4276c 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,6 +1,15 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; +import { + EDITOR_TYPE_DIFF, + EDITOR_CODE_INSTANCE_FN, + EDITOR_DIFF_INSTANCE_FN, +} from '~/editor/constants'; +import EditorLite from '~/editor/editor_lite'; +import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext'; import { deprecatedCreateFlash as flash } from '~/flash'; +import ModelManager from '~/ide/lib/common/model_manager'; +import { defaultDiffEditorOptions, defaultEditorOptions } from '~/ide/lib/editor_options'; import { __ } from '~/locale'; import { WEBIDE_MARK_FILE_CLICKED, @@ -20,7 +29,6 @@ import { FILE_VIEW_MODE_PREVIEW, } from '../constants'; import eventHub from '../eventhub'; -import Editor from '../lib/editor'; import { getRulesWithTraversal } from '../lib/editorconfig/parser'; import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; import { getFileEditorOrDefault } from '../stores/modules/editor/utils'; @@ -46,6 +54,9 @@ export default { content: '', images: {}, rules: {}, + globalEditor: null, + modelManager: new ModelManager(), + isEditorLoading: true, }; }, computed: { @@ -132,6 +143,7 @@ export default { // Compare key to allow for files opened in review mode to be cached differently if (oldVal.key !== this.file.key) { + this.isEditorLoading = true; this.initEditor(); if (this.currentActivityView !== leftSidebarViews.edit.name) { @@ -149,6 +161,7 @@ export default { } }, viewer() { + this.isEditorLoading = false; if (!this.file.pending) { this.createEditorInstance(); } @@ -181,11 +194,11 @@ export default { }, }, beforeDestroy() { - this.editor.dispose(); + this.globalEditor.dispose(); }, mounted() { - if (!this.editor) { - this.editor = Editor.create(this.$store, this.editorOptions); + if (!this.globalEditor) { + this.globalEditor = new EditorLite(); } this.initEditor(); @@ -211,8 +224,6 @@ export default { return; } - this.editor.clearEditor(); - this.registerSchemaForFile(); Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()]) @@ -251,20 +262,45 @@ export default { return; } - this.editor.dispose(); + const isDiff = this.viewer !== viewerTypes.edit; + const shouldDisposeEditor = isDiff !== (this.editor?.getEditorType() === EDITOR_TYPE_DIFF); - this.$nextTick(() => { - if (this.viewer === viewerTypes.edit) { - this.editor.createInstance(this.$refs.editor); - } else { - this.editor.createDiffInstance(this.$refs.editor); + if (this.editor && !shouldDisposeEditor) { + this.setupEditor(); + } else { + if (this.editor && shouldDisposeEditor) { + this.editor.dispose(); } + const instanceOptions = isDiff ? defaultDiffEditorOptions : defaultEditorOptions; + const method = isDiff ? EDITOR_DIFF_INSTANCE_FN : EDITOR_CODE_INSTANCE_FN; - this.setupEditor(); - }); + this.editor = this.globalEditor[method]({ + el: this.$refs.editor, + blobPath: this.file.path, + blobGlobalId: this.file.key, + blobContent: this.content || this.file.content, + ...instanceOptions, + ...this.editorOptions, + }); + + this.editor.use( + new EditorWebIdeExtension({ + instance: this.editor, + modelManager: this.modelManager, + store: this.$store, + file: this.file, + options: this.editorOptions, + }), + ); + + this.$nextTick(() => { + this.setupEditor(); + }); + } }, + setupEditor() { - if (!this.file || !this.editor.instance || this.file.loading) return; + if (!this.file || !this.editor || this.file.loading) return; const head = this.getStagedFile(this.file.path); @@ -279,6 +315,8 @@ export default { this.editor.attachModel(this.model); } + this.isEditorLoading = false; + this.model.updateOptions(this.rules); this.model.onChange((model) => { @@ -298,7 +336,7 @@ export default { }); }); - this.editor.setPosition({ + this.editor.setPos({ lineNumber: this.fileEditor.editorRow, column: this.fileEditor.editorColumn, }); @@ -308,6 +346,10 @@ export default { fileLanguage: this.model.language, }); + this.$nextTick(() => { + this.editor.updateDimensions(); + }); + this.$emit('editorSetup'); if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) { eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION); @@ -344,7 +386,7 @@ export default { }); }, onPaste(event) { - const editor = this.editor.instance; + const { editor } = this; const reImage = /^image\/(png|jpg|jpeg|gif)$/; const file = event.clipboardData.files[0]; @@ -395,6 +437,7 @@ export default { <a href="javascript:void(0);" role="button" + data-testid="edit-tab" @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })" > {{ __('Edit') }} @@ -404,6 +447,7 @@ export default { <a href="javascript:void(0);" role="button" + data-testid="preview-tab" @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })" >{{ previewMode.previewTitle }}</a > @@ -414,6 +458,7 @@ export default { <div v-show="showEditor" ref="editor" + :key="`content-editor`" :class="{ 'is-readonly': isCommitModeActive, 'is-deleted': file.deleted, @@ -421,6 +466,8 @@ export default { }" class="multi-file-editor-holder" data-qa-selector="editor_container" + data-testid="editor-container" + :data-editor-loading="isEditorLoading" @focusout="triggerFilesChange" ></div> <content-viewer diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index d28751c9571..64ec2cc67c7 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTab } from '@gitlab/ui'; import { mapActions, mapGetters } from 'vuex'; import { __, sprintf } from '~/locale'; @@ -13,6 +13,7 @@ export default { FileIcon, GlIcon, ChangedFileIcon, + GlTab, }, props: { tab: { @@ -71,29 +72,30 @@ export default { </script> <template> - <li - :class="{ - active: tab.active, - disabled: tab.pending, - }" + <gl-tab + :active="tab.active" + :disabled="tab.pending" + :title="tab.name" @click="clickFile(tab)" @mouseover="mouseOverTab" @mouseout="mouseOutTab" > - <div :title="getUrlForPath(tab.path)" class="multi-file-tab"> - <file-icon :file-name="tab.name" :size="16" /> - {{ tab.name }} - <file-status-icon :file="tab" /> - </div> - <button - :aria-label="closeLabel" - :disabled="tab.pending" - type="button" - class="multi-file-tab-close" - @click.stop.prevent="closeFile(tab)" - > - <gl-icon v-if="!showChangedIcon" :size="12" name="close" /> - <changed-file-icon v-else :file="tab" /> - </button> - </li> + <template #title> + <div :title="getUrlForPath(tab.path)" class="multi-file-tab"> + <file-icon :file-name="tab.name" :size="16" /> + {{ tab.name }} + <file-status-icon :file="tab" /> + </div> + <button + :aria-label="closeLabel" + :disabled="tab.pending" + type="button" + class="multi-file-tab-close" + @click.stop.prevent="closeFile(tab)" + > + <gl-icon v-if="!showChangedIcon" :size="12" name="close" /> + <changed-file-icon v-else :file="tab" /> + </button> + </template> + </gl-tab> </template> diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index c03694e3619..932040c7fa5 100644 --- a/app/assets/javascripts/ide/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -1,10 +1,12 @@ <script> +import { GlTabs } from '@gitlab/ui'; import { mapActions, mapGetters } from 'vuex'; import RepoTab from './repo_tab.vue'; export default { components: { RepoTab, + GlTabs, }, props: { activeFile: { @@ -42,8 +44,8 @@ export default { <template> <div class="multi-file-tabs"> - <ul ref="tabsScroller" class="list-unstyled gl-mb-0"> + <gl-tabs> <repo-tab v-for="tab in files" :key="tab.key" :tab="tab" /> - </ul> + </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 1b4b59ef62f..f4a0f324e4a 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -53,7 +53,6 @@ export function initIde(el, options = {}) { promotionSvgPath: el.dataset.promotionSvgPath, }); this.setLinks({ - ciHelpPagePath: el.dataset.ciHelpPagePath, webIDEHelpPagePath: el.dataset.webIdeHelpPagePath, }); this.setInitialData({ diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index 7c5f48dcafc..a0a44ee74dc 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -33,6 +33,11 @@ export default { type: String, required: true, }, + canCreateGroup: { + type: Boolean, + required: false, + default: false, + }, }, data() { @@ -147,10 +152,15 @@ export default { </div> <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" /> <template v-else> - <gl-empty-state v-if="hasEmptyFilter" :title="__('Sorry, your filter produced no results')" /> + <gl-empty-state + v-if="hasEmptyFilter" + :title="__('Sorry, your filter produced no results')" + :description="__('To widen your search, change or remove filters above.')" + /> <gl-empty-state v-else-if="!hasGroups" - :title="s__('BulkImport|No groups available for import')" + :title="s__('BulkImport|You have no groups to import')" + :description="s__('Check your source instance permissions.')" /> <div v-else class="gl-display-flex gl-flex-direction-column gl-align-items-center"> <table class="gl-w-full"> @@ -166,6 +176,7 @@ export default { :key="group.id" :group="group" :available-namespaces="availableNamespaces" + :can-create-group="canCreateGroup" @update-target-namespace="updateTargetNamespace(group.id, $event)" @update-new-name="updateNewName(group.id, $event)" @import-group="importGroup(group.id)" diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue index 1707ab10c89..1f3eee0c141 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlIcon, GlLink, GlFormInput } from '@gitlab/ui'; import { joinPaths } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; import Select2Select from '~/vue_shared/components/select2_select.vue'; import ImportStatus from '../../components/import_status.vue'; import { STATUSES } from '../../constants'; @@ -23,6 +24,11 @@ export default { type: Array, required: true, }, + canCreateGroup: { + type: Boolean, + required: false, + default: false, + }, }, computed: { isDisabled() { @@ -34,11 +40,23 @@ export default { }, select2Options() { + const availableNamespacesData = this.availableNamespaces.map((namespace) => ({ + id: namespace.full_path, + text: namespace.full_path, + })); + + if (!this.canCreateGroup) { + return { data: availableNamespacesData }; + } + return { - data: this.availableNamespaces.map((namespace) => ({ - id: namespace.full_path, - text: namespace.full_path, - })), + data: [ + { id: '', text: s__('BulkImport|No parent') }, + { + text: s__('BulkImport|Existing groups'), + children: availableNamespacesData, + }, + ], }; }, }, diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js index cd837a840e4..0700358f6ce 100644 --- a/app/assets/javascripts/import_entities/import_groups/index.js +++ b/app/assets/javascripts/import_entities/import_groups/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { parseBoolean } from '~/lib/utils/common_utils'; import Translate from '~/vue_shared/translate'; import ImportTable from './components/import_table.vue'; import { createApolloClient } from './graphql/client_factory'; @@ -16,6 +17,7 @@ export function mountImportGroupsApp(mountElement) { createBulkImportPath, jobsPath, sourceUrl, + canCreateGroup, } = mountElement.dataset; const apolloProvider = new VueApollo({ defaultClient: createApolloClient({ @@ -35,6 +37,7 @@ export function mountImportGroupsApp(mountElement) { return createElement(ImportTable, { props: { sourceUrl, + canCreateGroup: parseBoolean(canCreateGroup), }, }); }, diff --git a/app/assets/javascripts/incidents_settings/constants.js b/app/assets/javascripts/incidents_settings/constants.js index fcac9c519c2..818af4ecb90 100644 --- a/app/assets/javascripts/incidents_settings/constants.js +++ b/app/assets/javascripts/incidents_settings/constants.js @@ -51,7 +51,7 @@ export const NO_ISSUE_TEMPLATE_SELECTED = { key: '', name: __('No template selec export const TAKING_INCIDENT_ACTION_DOCS_LINK = '/help/operations/metrics/alerts#trigger-actions-from-alerts'; export const ISSUE_TEMPLATES_DOCS_LINK = - '/help/user/project/description_templates#creating-issue-templates'; + '/help/user/project/description_templates#create-an-issue-template'; /* PagerDuty integration settings constants */ 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 af4e9acf4ba..22a767cfaae 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -1,7 +1,16 @@ <script> -import { GlFormGroup, GlFormCheckbox, GlFormRadio } from '@gitlab/ui'; +import { + GlFormGroup, + GlFormCheckbox, + GlFormRadio, + GlFormInput, + GlLink, + GlSprintf, +} from '@gitlab/ui'; import { mapGetters } from 'vuex'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; +import eventHub from '../event_hub'; const commentDetailOptions = [ { @@ -18,12 +27,41 @@ const commentDetailOptions = [ }, ]; +const ISSUE_TRANSITION_AUTO = 'auto'; +const ISSUE_TRANSITION_CUSTOM = 'custom'; + +const issueTransitionOptions = [ + { + value: ISSUE_TRANSITION_AUTO, + label: s__('JiraService|Move to Done'), + help: s__( + 'JiraService|Automatically transitions Jira issues to the "Done" category. %{linkStart}Learn more%{linkEnd}', + ), + link: helpPagePath('user/project/integrations/jira.html', { + anchor: 'automatic-issue-transitions', + }), + }, + { + value: ISSUE_TRANSITION_CUSTOM, + label: s__('JiraService|Use custom transitions'), + help: s__( + 'JiraService|Set a custom final state by using transition IDs. %{linkStart}Learn about transition IDs%{linkEnd}', + ), + link: helpPagePath('user/project/integrations/jira.html', { + anchor: 'custom-issue-transitions', + }), + }, +]; + export default { name: 'JiraTriggerFields', components: { GlFormGroup, GlFormCheckbox, GlFormRadio, + GlFormInput, + GlLink, + GlSprintf, }, props: { initialTriggerCommit: { @@ -43,21 +81,52 @@ export default { required: false, default: 'standard', }, + initialJiraIssueTransitionId: { + type: String, + required: false, + default: '', + }, }, data() { return { + validated: false, triggerCommit: this.initialTriggerCommit, triggerMergeRequest: this.initialTriggerMergeRequest, enableComments: this.initialEnableComments, commentDetail: this.initialCommentDetail, + jiraIssueTransitionId: this.initialJiraIssueTransitionId, + issueTransitionMode: this.initialJiraIssueTransitionId.length + ? ISSUE_TRANSITION_CUSTOM + : ISSUE_TRANSITION_AUTO, commentDetailOptions, + issueTransitionOptions, }; }, computed: { ...mapGetters(['isInheriting']), - showEnableComments() { + showTriggerSettings() { return this.triggerCommit || this.triggerMergeRequest; }, + validIssueTransitionId() { + return !this.validated || this.jiraIssueTransitionId.length > 0; + }, + }, + created() { + eventHub.$on('validateForm', this.validateForm); + }, + beforeDestroy() { + eventHub.$off('validateForm', this.validateForm); + }, + methods: { + validateForm() { + this.validated = true; + }, + showCustomIssueTransitions(currentOption) { + return ( + this.issueTransitionMode === ISSUE_TRANSITION_CUSTOM && + currentOption === ISSUE_TRANSITION_CUSTOM + ); + }, }, }; </script> @@ -89,7 +158,7 @@ export default { </gl-form-group> <gl-form-group - v-show="showEnableComments" + v-show="showTriggerSettings" :label="s__('Integrations|Comment settings:')" label-for="service[comment_on_event_enabled]" class="gl-pl-6" @@ -106,7 +175,7 @@ export default { </gl-form-group> <gl-form-group - v-show="showEnableComments && enableComments" + v-show="showTriggerSettings && enableComments" :label="s__('Integrations|Comment detail:')" label-for="service[comment_detail]" class="gl-pl-9" @@ -126,5 +195,51 @@ export default { </template> </gl-form-radio> </gl-form-group> + + <gl-form-group + v-show="showTriggerSettings" + :label="s__('JiraService|Transition Jira issues to their final state:')" + class="gl-pl-6" + data-testid="issue-transition-settings" + > + <input type="hidden" name="service[jira_issue_transition_id]" value="" /> + + <gl-form-radio + v-for="issueTransitionOption in issueTransitionOptions" + :key="issueTransitionOption.value" + v-model="issueTransitionMode" + :value="issueTransitionOption.value" + :disabled="isInheriting" + :data-qa-selector="`service_issue_transition_mode_${issueTransitionOption.value}`" + > + {{ issueTransitionOption.label }} + + <template v-if="showCustomIssueTransitions(issueTransitionOption.value)"> + <gl-form-input + v-model="jiraIssueTransitionId" + name="service[jira_issue_transition_id]" + type="text" + class="gl-my-3" + data-qa-selector="service_jira_issue_transition_id_field" + :placeholder="s__('JiraService|For example, 12, 24')" + :disabled="isInheriting" + :required="true" + :state="validIssueTransitionId" + /> + + <span class="invalid-feedback"> + {{ s__('This field is required.') }} + </span> + </template> + + <template #help> + <gl-sprintf :message="issueTransitionOption.help"> + <template #link="{ content }"> + <gl-link :href="issueTransitionOption.link" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-form-radio> + </gl-form-group> </div> </template> diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index ab9bdd9ca2e..1ae353ab6e3 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -28,6 +28,7 @@ function parseDatasetToProps(data) { testPath, resetPath, vulnerabilitiesIssuetype, + jiraIssueTransitionId, ...booleanAttributes } = data; const { @@ -59,6 +60,7 @@ function parseDatasetToProps(data) { initialTriggerMergeRequest: mergeRequestEvents, initialEnableComments: enableComments, initialCommentDetail: commentDetail, + initialJiraIssueTransitionId: jiraIssueTransitionId, }, jiraIssuesProps: { showJiraIssuesIntegration, diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue new file mode 100644 index 00000000000..4a72e97db8c --- /dev/null +++ b/app/assets/javascripts/invite_members/components/group_select.vue @@ -0,0 +1,103 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownText, GlSearchBoxByType } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import Api from '~/api'; +import { s__ } from '~/locale'; +import { SEARCH_DELAY } from '../constants'; + +export default { + name: 'GroupSelect', + components: { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + }, + model: { + prop: 'selectedGroup', + }, + data() { + return { + isFetching: false, + groups: [], + selectedGroup: {}, + searchTerm: '', + }; + }, + computed: { + selectedGroupName() { + return this.selectedGroup.name || this.$options.i18n.dropdownText; + }, + isFetchResultEmpty() { + return this.groups.length === 0; + }, + }, + watch: { + searchTerm() { + this.retrieveGroups(); + }, + }, + mounted() { + this.retrieveGroups(); + }, + methods: { + retrieveGroups: debounce(function debouncedRetrieveGroups() { + this.isFetching = true; + return Api.groups(this.searchTerm, this.$options.defaultFetchOptions) + .then((response) => { + this.groups = response.map((group) => ({ + id: group.id, + name: group.full_name, + path: group.path, + })); + this.isFetching = false; + }) + .catch(() => { + this.isFetching = false; + }); + }, SEARCH_DELAY), + selectGroup(group) { + this.selectedGroup = group; + + this.$emit('input', this.selectedGroup); + }, + }, + i18n: { + dropdownText: s__('GroupSelect|Select a group'), + searchPlaceholder: s__('GroupSelect|Search groups'), + emptySearchResult: s__('GroupSelect|No matching results'), + }, + defaultFetchOptions: { + exclude_internal: true, + active: true, + }, +}; +</script> +<template> + <div> + <gl-dropdown + data-testid="group-select-dropdown" + :text="selectedGroupName" + block + menu-class="gl-w-full!" + > + <gl-search-box-by-type + v-model.trim="searchTerm" + :is-loading="isFetching" + :placeholder="$options.i18n.searchPlaceholder" + data-qa-selector="group_select_dropdown_search_field" + /> + <gl-dropdown-item + v-for="group in groups" + :key="group.id" + :name="group.name" + @click="selectGroup(group)" + > + {{ group.name }} + </gl-dropdown-item> + <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message"> + <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> + </gl-dropdown-text> + </gl-dropdown> + </div> +</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 new file mode 100644 index 00000000000..c9de078319a --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue @@ -0,0 +1,34 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + components: { + GlButton, + }, + props: { + displayText: { + type: String, + required: false, + default: s__('InviteMembers|Invite a group'), + }, + classes: { + type: String, + required: false, + default: '', + }, + }, + methods: { + openModal() { + eventHub.$emit('openModal', { inviteeType: 'group' }); + }, + }, +}; +</script> + +<template> + <gl-button :class="classes" data-qa-selector="invite_a_group_button" @click="openModal"> + {{ displayText }} + </gl-button> +</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 f5a65882fba..cd9c3b0b5d3 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -11,9 +11,10 @@ import { } from '@gitlab/ui'; import { partition, isString } from 'lodash'; import Api from '~/api'; +import GroupSelect from '~/invite_members/components/group_select.vue'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; -import { s__, __, sprintf } from '~/locale'; +import { s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; export default { @@ -28,6 +29,7 @@ export default { GlButton, GlFormInput, MembersTokenSelect, + GroupSelect, }, props: { id: { @@ -60,21 +62,21 @@ export default { visible: true, modalId: 'invite-members-modal', selectedAccessLevel: this.defaultAccessLevel, + inviteeType: 'members', newUsersToInvite: [], selectedDate: undefined, + groupToBeSharedWith: {}, }; }, computed: { - inviteToName() { - return this.name.toUpperCase(); - }, - inviteToType() { - return this.isProject ? __('project') : __('group'); + isInviteGroup() { + return this.inviteeType === 'group'; }, introText() { - return sprintf(s__("InviteMembersModal|You're inviting members to the %{name} %{type}"), { - name: this.inviteToName, - type: this.inviteToType, + const inviteTo = this.isProject ? 'toProject' : 'toGroup'; + + return sprintf(this.$options.labels[this.inviteeType][inviteTo].introText, { + name: this.name.toUpperCase(), }); }, toastOptions() { @@ -82,12 +84,12 @@ export default { onComplete: () => { this.selectedAccessLevel = this.defaultAccessLevel; this.newUsersToInvite = []; + this.groupToBeSharedWith = {}; }, }; }, basePostData() { return { - access_level: this.selectedAccessLevel, expires_at: this.selectedDate, format: 'json', }; @@ -97,9 +99,16 @@ export default { (key) => this.accessLevels[key] === Number(this.selectedAccessLevel), ); }, + inviteDisabled() { + return ( + this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0 + ); + }, }, mounted() { - eventHub.$on('openModal', this.openModal); + eventHub.$on('openModal', (options) => { + this.openModal(options); + }); }, methods: { partitionNewUsersToInvite() { @@ -113,26 +122,42 @@ export default { usersToAddById.map((user) => user.id).join(','), ]; }, - openModal() { + openModal({ inviteeType }) { + this.inviteeType = inviteeType; + this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, closeModal() { this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, sendInvite() { - this.submitForm(); + if (this.isInviteGroup) { + this.submitShareWithGroup(); + } else { + this.submitInviteMembers(); + } this.closeModal(); }, cancelInvite() { this.selectedAccessLevel = this.defaultAccessLevel; this.selectedDate = undefined; - this.newUsersToInvite = ''; + this.newUsersToInvite = []; + this.groupToBeSharedWith = {}; this.closeModal(); }, changeSelectedItem(item) { this.selectedAccessLevel = item; }, - submitForm() { + submitShareWithGroup() { + const apiShareWithGroup = this.isProject + ? Api.projectShareWithGroup.bind(Api) + : Api.groupShareWithGroup.bind(Api); + + apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id)) + .then(this.showToastMessageSuccess) + .catch(this.showToastMessageError); + }, + submitInviteMembers() { const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); const promises = []; @@ -155,10 +180,25 @@ export default { Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError); }, inviteByEmailPostData(usersToInviteByEmail) { - return { ...this.basePostData, email: usersToInviteByEmail }; + return { + ...this.basePostData, + email: usersToInviteByEmail, + access_level: this.selectedAccessLevel, + }; }, addByUserIdPostData(usersToAddById) { - return { ...this.basePostData, user_id: usersToAddById }; + return { + ...this.basePostData, + user_id: usersToAddById, + access_level: this.selectedAccessLevel, + }; + }, + shareWithGroupPostData(groupToBeSharedWith) { + return { + ...this.basePostData, + group_id: groupToBeSharedWith, + group_access: this.selectedAccessLevel, + }; }, showToastMessageSuccess() { this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); @@ -170,9 +210,28 @@ export default { }, }, labels: { - modalTitle: s__('InviteMembersModal|Invite team members'), - newUsersToInvite: s__('InviteMembersModal|GitLab member or Email address'), - userPlaceholder: s__('InviteMembersModal|Search for members to invite'), + members: { + modalTitle: s__('InviteMembersModal|Invite team members'), + searchField: s__('InviteMembersModal|GitLab member or Email address'), + placeHolder: s__('InviteMembersModal|Search for members to invite'), + toGroup: { + introText: s__("InviteMembersModal|You're inviting members to the %{name} group"), + }, + toProject: { + introText: s__("InviteMembersModal|You're inviting members to the %{name} project"), + }, + }, + group: { + modalTitle: s__('InviteMembersModal|Invite a group'), + searchField: s__('InviteMembersModal|Select a group to invite'), + placeHolder: s__('InviteMembersModal|Search for a group to invite'), + toGroup: { + introText: s__("InviteMembersModal|You're inviting a group to the %{name} group"), + }, + toProject: { + introText: s__("InviteMembersModal|You're inviting a group to the %{name} project"), + }, + }, accessLevel: s__('InviteMembersModal|Choose a role permission'), accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'), @@ -189,27 +248,34 @@ export default { <gl-modal :modal-id="modalId" size="sm" - :title="$options.labels.modalTitle" + data-qa-selector="invite_members_modal_content" + :title="$options.labels[inviteeType].modalTitle" :header-close-label="$options.labels.headerCloseLabel" > - <div class="gl-ml-5 gl-mr-5"> - <div>{{ introText }}</div> + <div> + <p ref="introText">{{ introText }}</p> <label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{ - $options.labels.newUsersToInvite + $options.labels[inviteeType].searchField }}</label> <div class="gl-mt-2"> <members-token-select + v-if="!isInviteGroup" v-model="newUsersToInvite" - :label="$options.labels.newUsersToInvite" :aria-labelledby="$options.membersTokenSelectLabelId" - :placeholder="$options.labels.userPlaceholder" + :placeholder="$options.labels[inviteeType].placeHolder" /> + <group-select v-if="isInviteGroup" v-model="groupToBeSharedWith" /> </div> - <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label> + <label class="gl-font-weight-bold gl-mt-3">{{ $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" v-bind="$attrs" :text="selectedRoleName"> + <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" @@ -223,7 +289,7 @@ export default { </gl-dropdown> </div> - <div class="gl-mt-2"> + <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> @@ -231,7 +297,7 @@ export default { </gl-sprintf> </div> - <label class="gl-font-weight-bold gl-mt-5" for="expires_at">{{ + <label class="gl-font-weight-bold 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"> @@ -253,15 +319,16 @@ export default { </div> <template #modal-footer> - <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-p-3"> + <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"> <gl-button ref="cancelButton" @click="cancelInvite"> {{ $options.labels.cancelButtonText }} </gl-button> <div class="gl-mr-3"></div> <gl-button ref="inviteButton" - :disabled="!newUsersToInvite" + :disabled="inviteDisabled" variant="success" + data-qa-selector="invite_button" @click="sendInvite" >{{ $options.labels.inviteButtonText }}</gl-button > 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 eb97c458f88..f8cc74511d9 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -27,14 +27,14 @@ export default { }, methods: { openModal() { - eventHub.$emit('openModal'); + eventHub.$emit('openModal', { inviteeType: 'members' }); }, }, }; </script> <template> - <gl-link :class="classes" @click="openModal"> + <gl-link :class="classes" data-qa-selector="invite_members_button" @click="openModal"> <div v-if="icon" class="nav-icon-container"> <gl-icon :size="16" :name="icon" /> </div> diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index 233a214013b..db6a7888786 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -3,7 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/u import { debounce } from 'lodash'; import { __ } from '~/locale'; import { getUsers } from '~/rest_api'; -import { USER_SEARCH_DELAY } from '../constants'; +import { SEARCH_DELAY } from '../constants'; export default { components: { @@ -67,7 +67,7 @@ export default { .catch(() => { this.loading = false; }); - }, USER_SEARCH_DELAY), + }, SEARCH_DELAY), handleInput() { this.$emit('input', this.selectedTokens); }, diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 1ff2125c292..2044dad896f 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -1 +1 @@ -export const USER_SEARCH_DELAY = 200; +export const SEARCH_DELAY = 200; diff --git a/app/assets/javascripts/invite_members/init_invite_group_trigger.js b/app/assets/javascripts/invite_members/init_invite_group_trigger.js new file mode 100644 index 00000000000..c01bb1bae28 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_invite_group_trigger.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import InviteGroupTrigger from '~/invite_members/components/invite_group_trigger.vue'; + +export default function initInviteGroupTrigger() { + const el = document.querySelector('.js-invite-group-trigger'); + + if (!el) { + return false; + } + + return new Vue({ + el, + render: (createElement) => + createElement(InviteGroupTrigger, { + props: { + ...el.dataset, + }, + }), + }); +} diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index e70c18040b3..d1a8d334796 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -307,7 +307,7 @@ export default { }); }, - updateAndShowForm(templates = []) { + updateAndShowForm(templates = {}) { if (!this.showForm) { this.showForm = true; this.store.setFormState({ diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue index dbec6f15cab..570bc7df3cf 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -13,9 +13,9 @@ export default { required: true, }, issuableTemplates: { - type: Array, + type: [Object, Array], required: false, - default: () => [], + default: () => {}, }, projectPath: { type: String, diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index b7425448052..76ea489fb86 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -26,9 +26,9 @@ export default { required: true, }, issuableTemplates: { - type: Array, + type: [Object, Array], required: false, - default: () => [], + default: () => {}, }, issuableType: { type: String, @@ -72,7 +72,7 @@ export default { }, computed: { hasIssuableTemplates() { - return this.issuableTemplates.length; + return Object.values(Object(this.issuableTemplates)).length; }, showLockedWarning() { return this.formState.lockedWarningVisible && !this.formState.updateLoading; diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 06bbd406e3a..a50913d3455 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -11,7 +11,7 @@ export default class Store { lockedWarningVisible: false, updateLoading: false, lock_version: 0, - issuableTemplates: [], + issuableTemplates: {}, }; } diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js index 19d1e0eebcb..f1e6bd2419a 100644 --- a/app/assets/javascripts/issue_show/utils/parse_data.js +++ b/app/assets/javascripts/issue_show/utils/parse_data.js @@ -1,5 +1,5 @@ +import * as Sentry from '@sentry/browser'; import { sanitize } from '~/lib/dompurify'; -import * as Sentry from '~/sentry/wrapper'; // We currently load + parse the data from the issue app and related merge request let cachedParsedData; diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue index a4ba86dc6a1..4290971623e 100644 --- a/app/assets/javascripts/jira_connect/components/app.vue +++ b/app/assets/javascripts/jira_connect/components/app.vue @@ -60,15 +60,15 @@ export default { <template> <div> - <gl-alert v-if="errorMessage" class="gl-mb-6" variant="danger" :dismissible="false"> + <gl-alert v-if="errorMessage" class="gl-mb-7" variant="danger" :dismissible="false"> {{ errorMessage }} </gl-alert> - <h2>{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> + <h2 class="gl-text-center">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> <div v-if="showNewUI" - class="gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" + class="jira-connect-app-body gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" > <h5 class="gl-align-self-center gl-mb-0" data-testid="new-jira-connect-ui-heading"> {{ s__('Integrations|Linked namespaces') }} diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js index 7191fce3c33..96029e2711b 100644 --- a/app/assets/javascripts/jira_connect/index.js +++ b/app/assets/javascripts/jira_connect/index.js @@ -77,6 +77,7 @@ export async function initJiraConnect() { Vue.use(GlFeatureFlagsPlugin); const { groupsPath, subscriptionsPath, usersPath } = el.dataset; + AP.sizeToParent(); return new Vue({ el, diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 4dab796d8a4..81b9db6b4d5 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -361,10 +361,8 @@ export default class MergeRequestTabs { return createElement(CommitPipelinesTable, { props: { endpoint: pipelineTableViewEl.dataset.endpoint, - helpPagePath: pipelineTableViewEl.dataset.helpPagePath, emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath, errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath, - autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath, canCreatePipelineInTargetProject: Boolean( mrWidgetData?.can_create_pipeline_in_target_project, ), diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 8522ac6a57d..a0b4fd0b608 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -1,7 +1,7 @@ +import * as Sentry from '@sentry/browser'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import * as Sentry from '~/sentry/wrapper'; import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants'; diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index 27408bc3354..6f0745d4fb0 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -50,8 +50,8 @@ export default { <div class="discussion-with-resolve-btn clearfix"> <reply-placeholder data-qa-selector="discussion_reply_tab" - :button-text="s__('MergeRequests|Reply...')" - @onClick="$emit('showReplyForm')" + :placeholder-text="__('Reply…')" + @focus="$emit('showReplyForm')" /> <div v-if="userCanResolveDiscussion" class="btn-group discussion-actions" role="group"> diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue index 0204169214b..1165a869d2b 100644 --- a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue +++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue @@ -1,23 +1,30 @@ <script> +import { __ } from '~/locale'; + export default { name: 'ReplyPlaceholder', props: { - buttonText: { + placeholderText: { + type: String, + required: false, + default: __('Reply…'), + }, + labelText: { type: String, - required: true, + required: false, + default: __('Reply to comment'), }, }, }; </script> <template> - <button - ref="button" - type="button" - class="js-vue-discussion-reply btn btn-text-field" - :title="s__('MergeRequests|Add a reply')" - @click="$emit('onClick')" - > - {{ buttonText }} - </button> + <textarea + ref="textarea" + rows="1" + class="reply-placeholder-text-field js-vue-discussion-reply" + :placeholder="placeholderText" + :aria-label="labelText" + @focus="$emit('focus')" + ></textarea> </template> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 653bc450d0b..a28c467117a 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -345,7 +345,7 @@ export default { class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form" data-qa-selector="reply_field" dir="auto" - :aria-label="__('Description')" + :aria-label="__('Reply to comment')" :placeholder="__('Write a comment or drag your files here…')" @keydown.meta.enter="handleKeySubmit()" @keydown.ctrl.enter="handleKeySubmit()" diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js index 627e405c75c..592e634e034 100644 --- a/app/assets/javascripts/notes/stores/utils.js +++ b/app/assets/javascripts/notes/stores/utils.js @@ -1,4 +1,4 @@ -import { trimFirstCharOfLineContent } from '~/diffs/store/utils'; +import { trimFirstCharOfLineContent } from '~/diffs/store/utils'; // eslint-disable-line import/no-deprecated import createGqClient, { fetchPolicies } from '~/lib/graphql'; import AjaxCache from '~/lib/utils/ajax_cache'; import { sprintf, __ } from '~/locale'; @@ -34,7 +34,7 @@ export const hasQuickActions = (note) => createQuickActionsRegex().test(note); export const stripQuickActions = (note) => note.replace(createQuickActionsRegex(), '').trim(); export const prepareDiffLines = (diffLines) => - diffLines.map((line) => ({ ...trimFirstCharOfLineContent(line) })); + diffLines.map((line) => ({ ...trimFirstCharOfLineContent(line) })); // eslint-disable-line import/no-deprecated export const gqClient = createGqClient( {}, diff --git a/app/assets/javascripts/notifications/constants.js b/app/assets/javascripts/notifications/constants.js index 07c569a0293..c12f6a75f96 100644 --- a/app/assets/javascripts/notifications/constants.js +++ b/app/assets/javascripts/notifications/constants.js @@ -22,10 +22,10 @@ export const i18n = { owner_disabled: __('Notifications have been disabled by the project or group owner'), }, updateNotificationLevelErrorMessage: __( - 'An error occured while updating the notification settings. Please try again.', + 'An error occurred while updating the notification settings. Please try again.', ), loadNotificationLevelErrorMessage: __( - 'An error occured while loading the notification settings. Please try again.', + 'An error occurred while loading the notification settings. Please try again.', ), customNotificationsModal: { title: __('Custom notification events'), diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js index d47eb8c3421..25a55200df2 100644 --- a/app/assets/javascripts/packages/list/constants.js +++ b/app/assets/javascripts/packages/list/constants.js @@ -71,7 +71,7 @@ export const PACKAGE_TYPES = [ type: PackageType.MAVEN, }, { - title: s__('PackageRegistry|NPM'), + title: s__('PackageRegistry|npm'), type: PackageType.NPM, }, { diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js index 677550f77ec..d34372e89b6 100644 --- a/app/assets/javascripts/packages/shared/utils.js +++ b/app/assets/javascripts/packages/shared/utils.js @@ -14,7 +14,7 @@ export const getPackageTypeLabel = (packageType) => { case PackageType.MAVEN: return s__('PackageType|Maven'); case PackageType.NPM: - return s__('PackageType|NPM'); + return s__('PackageType|npm'); case PackageType.NUGET: return s__('PackageType|NuGet'); case PackageType.PYPI: diff --git a/app/assets/javascripts/pages/admin/instance_statistics/index.js b/app/assets/javascripts/pages/admin/instance_statistics/index.js deleted file mode 100644 index d6b0a834ce3..00000000000 --- a/app/assets/javascripts/pages/admin/instance_statistics/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initInstanceStatisticsApp from '~/analytics/instance_statistics'; - -document.addEventListener('DOMContentLoaded', () => initInstanceStatisticsApp()); diff --git a/app/assets/javascripts/pages/admin/usage_trends/index.js b/app/assets/javascripts/pages/admin/usage_trends/index.js new file mode 100644 index 00000000000..23d2bd85979 --- /dev/null +++ b/app/assets/javascripts/pages/admin/usage_trends/index.js @@ -0,0 +1,3 @@ +import initUsageTrendsApp from '~/analytics/usage_trends'; + +initUsageTrendsApp(); diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index 95ee512b71a..176d2406751 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -6,6 +6,7 @@ import TransferDropdown from '~/groups/transfer_dropdown'; import groupsSelect from '~/groups_select'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import projectSelect from '~/project_select'; +import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; import setupTransferEdit from '~/transfer_edit'; @@ -24,5 +25,7 @@ document.addEventListener('DOMContentLoaded', () => { projectSelect(); + initSearchSettings(); + 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 3496f699b06..63afc6fe94d 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 Vue from 'vue'; import { groupMemberRequestFormatter } from '~/groups/members/utils'; import groupsSelect from '~/groups_select'; +import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { s__ } from '~/locale'; @@ -70,5 +71,6 @@ memberExpirationDate('.js-access-expiration-date-groups'); mountRemoveMemberModal(); initInviteMembersModal(); initInviteMembersTrigger(); +initInviteGroupTrigger(); new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index 378b8663777..b31a926dbe9 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -4,6 +4,7 @@ import initSharedRunnersForm from '~/group_settings/mount_shared_runners'; import { FILTERED_SEARCH } from '~/pages/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; +import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; document.addEventListener('DOMContentLoaded', () => { @@ -21,4 +22,6 @@ document.addEventListener('DOMContentLoaded', () => { initVariableList(); initInstallRunner(); + + initSearchSettings(); }); diff --git a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js index 3b922622d2c..d13bf026777 100644 --- a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js +++ b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js @@ -1,3 +1,6 @@ import bundle from '~/packages_and_registries/settings/group/bundle'; +import initSearchSettings from '~/search_settings'; bundle(); + +document.addEventListener('DOMContentLoaded', initSearchSettings); diff --git a/app/assets/javascripts/pages/groups/settings/repository/show/index.js b/app/assets/javascripts/pages/groups/settings/repository/show/index.js index a1bcf6dbf57..33c5c40f2be 100644 --- a/app/assets/javascripts/pages/groups/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/repository/show/index.js @@ -1,4 +1,5 @@ import DueDateSelectors from '~/due_date_select'; +import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; document.addEventListener('DOMContentLoaded', () => { @@ -6,4 +7,6 @@ document.addEventListener('DOMContentLoaded', () => { initSettingsPanels(); new DueDateSelectors(); // eslint-disable-line no-new + + initSearchSettings(); }); diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js index bbe84322462..43fd5375222 100644 --- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js @@ -1,3 +1,5 @@ +/* eslint-disable no-new */ + import Vue from 'vue'; import Vuex from 'vuex'; import EditUserList from '~/user_lists/components/edit_user_list.vue'; @@ -5,15 +7,13 @@ import createStore from '~/user_lists/store/edit'; Vue.use(Vuex); -document.addEventListener('DOMContentLoaded', () => { - const el = document.getElementById('js-edit-user-list'); - const { userListsDocsPath } = el.dataset; - return new Vue({ - el, - store: createStore(el.dataset), - provide: { userListsDocsPath }, - render(h) { - return h(EditUserList, {}); - }, - }); +const el = document.getElementById('js-edit-user-list'); +const { userListsDocsPath } = el.dataset; +new Vue({ + el, + store: createStore(el.dataset), + provide: { userListsDocsPath }, + render(h) { + return h(EditUserList, {}); + }, }); diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js index 679f0af8efc..e855447d5ce 100644 --- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js @@ -1,3 +1,5 @@ +/* eslint-disable no-new */ + import Vue from 'vue'; import Vuex from 'vuex'; import NewUserList from '~/user_lists/components/new_user_list.vue'; @@ -5,18 +7,16 @@ import createStore from '~/user_lists/store/new'; Vue.use(Vuex); -document.addEventListener('DOMContentLoaded', () => { - const el = document.getElementById('js-new-user-list'); - const { userListsDocsPath, featureFlagsPath } = el.dataset; - return new Vue({ - el, - store: createStore(el.dataset), - provide: { - userListsDocsPath, - featureFlagsPath, - }, - render(h) { - return h(NewUserList); - }, - }); +const el = document.getElementById('js-new-user-list'); +const { userListsDocsPath, featureFlagsPath } = el.dataset; +new Vue({ + el, + store: createStore(el.dataset), + provide: { + userListsDocsPath, + featureFlagsPath, + }, + render(h) { + return h(NewUserList); + }, }); diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index ed11b07be4a..8d403b7688a 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import { deprecatedCreateFlash as flash } from '~/flash'; import groupsSelect from '~/groups_select'; +import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { __ } from '~/locale'; @@ -29,6 +30,7 @@ memberExpirationDate('.js-access-expiration-date-groups'); mountRemoveMemberModal(); initInviteMembersModal(); initInviteMembersTrigger(); +initInviteGroupTrigger(); new Members(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index be9259ec3ca..b7e8d4b03ac 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -6,6 +6,7 @@ import initDeployFreeze from '~/deploy_freeze'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle'; import registrySettingsApp from '~/registry/settings/registry_settings_bundle'; +import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; document.addEventListener('DOMContentLoaded', () => { @@ -42,4 +43,6 @@ document.addEventListener('DOMContentLoaded', () => { } initInstallRunner(); + + initSearchSettings(); }); diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js index 3a46241e2eb..4a800ab150d 100644 --- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -3,6 +3,7 @@ import mountErrorTrackingForm from '~/error_tracking_settings'; import mountGrafanaIntegration from '~/grafana_integration'; import initIncidentsSettings from '~/incidents_settings'; import mountOperationSettings from '~/operation_settings'; +import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; initIncidentsSettings(); @@ -13,3 +14,7 @@ if (!IS_EE) { initSettingsPanels(); } mountAlertsSettings(document.querySelector('.js-alerts-settings')); + +document.addEventListener('DOMContentLoaded', () => { + initSearchSettings(); +}); diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js index e90954c14c5..c7bcbb83051 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js @@ -1,4 +1,5 @@ import MirrorRepos from '~/mirrors/mirror_repos'; +import initSearchSettings from '~/search_settings'; import initForm from '../form'; document.addEventListener('DOMContentLoaded', () => { @@ -6,4 +7,6 @@ document.addEventListener('DOMContentLoaded', () => { const mirrorReposContainer = document.querySelector('.js-mirror-settings'); if (mirrorReposContainer) new MirrorRepos(mirrorReposContainer).init(); + + initSearchSettings(); }); diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 0494dad6e33..e5ec9976ac5 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -24,9 +24,12 @@ new UserCallout({ }); // Project show page loads different overview content based on user preferences -const treeSlider = document.getElementById('js-tree-list'); -if (treeSlider) { + +if (document.querySelector('.js-upload-blob-form')) { initUploadForm(); +} + +if (document.getElementById('js-tree-list')) { initTree(); } diff --git a/app/assets/javascripts/pages/projects/tags/new/index.js b/app/assets/javascripts/pages/projects/tags/new/index.js index 11a19a673b1..b071e7a45fc 100644 --- a/app/assets/javascripts/pages/projects/tags/new/index.js +++ b/app/assets/javascripts/pages/projects/tags/new/index.js @@ -3,8 +3,6 @@ import GLForm from '../../../../gl_form'; import RefSelectDropdown from '../../../../ref_select_dropdown'; import ZenMode from '../../../../zen_mode'; -document.addEventListener('DOMContentLoaded', () => { - new ZenMode(); // eslint-disable-line no-new - new GLForm($('.tag-form')); // eslint-disable-line no-new - new RefSelectDropdown($('.js-branch-select')); // eslint-disable-line no-new -}); +new ZenMode(); // eslint-disable-line no-new +new GLForm($('.tag-form')); // eslint-disable-line no-new +new RefSelectDropdown($('.js-branch-select')); // eslint-disable-line no-new diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js index 069f3c265f3..4ac758550e0 100644 --- a/app/assets/javascripts/performance/constants.js +++ b/app/assets/javascripts/performance/constants.js @@ -54,3 +54,24 @@ export const MR_DIFFS_MARK_DIFF_FILES_END = 'mr-diffs-mark-diff-files-end'; // Measures export const MR_DIFFS_MEASURE_FILE_TREE_DONE = 'mr-diffs-measure-file-tree-done'; export const MR_DIFFS_MEASURE_DIFF_FILES_DONE = 'mr-diffs-measure-diff-files-done'; + +// +// Pipelines Detail namespace +// + +// Marks +export const PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START = + 'pipelines-detail-links-mark-calculate-start'; +export const PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END = + 'pipelines-detail-links-mark-calculate-end'; + +// Measures +export const PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION = + 'Pipelines Detail Graph: Links Calculation'; + +// Metrics +// Note: These strings must match the backend +// (defined in: app/services/ci/prometheus_metrics/observe_histograms_service.rb) +export const PIPELINES_DETAIL_LINK_DURATION = 'pipeline_graph_link_calculation_duration_seconds'; +export const PIPELINES_DETAIL_LINKS_TOTAL = 'pipeline_graph_links_total'; +export const PIPELINES_DETAIL_LINKS_JOB_RATIO = 'pipeline_graph_link_per_job_ratio'; 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 85789cd1fdf..232de605e07 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -30,6 +30,10 @@ export default { type: String, required: true, }, + statsUrl: { + type: String, + required: true, + }, }, detailedMetrics: [ { @@ -169,6 +173,9 @@ export default { class="ml-auto" @change-current-request="changeCurrentRequest" /> + <div v-if="statsUrl" id="peek-stats" class="view"> + <a class="gl-text-blue-300" :href="statsUrl">{{ s__('PerformanceBar|Stats') }}</a> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js index 522e34753e9..51b6108868f 100644 --- a/app/assets/javascripts/performance_bar/index.js +++ b/app/assets/javascripts/performance_bar/index.js @@ -29,6 +29,7 @@ const initPerformanceBar = (el) => { requestId: performanceBarData.requestId, peekUrl: performanceBarData.peekUrl, profileUrl: performanceBarData.profileUrl, + statsUrl: performanceBarData.statsUrl, }; }, mounted() { @@ -119,6 +120,7 @@ const initPerformanceBar = (el) => { requestId: this.requestId, peekUrl: this.peekUrl, profileUrl: this.profileUrl, + statsUrl: this.statsUrl, }, on: { 'add-request': this.addRequestManually, diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue index ab41c0170e9..1381cd2f6c3 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue @@ -1,12 +1,35 @@ <script> +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import PipelineStatus from './pipeline_status.vue'; import ValidationSegment from './validation_segment.vue'; +const baseClasses = ['gl-p-5', 'gl-bg-gray-10', 'gl-border-solid', 'gl-border-gray-100']; + +const pipelineStatusClasses = [ + ...baseClasses, + 'gl-border-1', + 'gl-border-b-0!', + 'gl-rounded-top-base', +]; + +const validationSegmentClasses = [...baseClasses, 'gl-border-1', 'gl-rounded-base']; + +const validationSegmentWithPipelineStatusClasses = [ + ...baseClasses, + 'gl-border-1', + 'gl-rounded-bottom-left-base', + 'gl-rounded-bottom-right-base', +]; + export default { - validationSegmentClasses: - 'gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base', + pipelineStatusClasses, + validationSegmentClasses, + validationSegmentWithPipelineStatusClasses, components: { + PipelineStatus, ValidationSegment, }, + mixins: [glFeatureFlagsMixin()], props: { ciConfigData: { type: Object, @@ -17,12 +40,25 @@ export default { required: true, }, }, + computed: { + showPipelineStatus() { + return this.glFeatures.pipelineStatusForPipelineEditor; + }, + // make sure corners are rounded correctly depending on if + // pipeline status is rendered + validationStyling() { + return this.showPipelineStatus + ? this.$options.validationSegmentWithPipelineStatusClasses + : this.$options.validationSegmentClasses; + }, + }, }; </script> <template> <div class="gl-mb-5"> + <pipeline-status v-if="showPipelineStatus" :class="$options.pipelineStatusClasses" /> <validation-segment - :class="$options.validationSegmentClasses" + :class="validationStyling" :loading="isCiConfigDataLoading" :ci-config="ciConfigData" /> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue new file mode 100644 index 00000000000..b1ea464be99 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -0,0 +1,120 @@ +<script> +import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { s__ } from '~/locale'; +import getCommitSha from '~/pipeline_editor/graphql/queries/client/commit_sha.graphql'; +import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; + +const POLL_INTERVAL = 10000; +export const i18n = { + fetchError: s__('Pipeline|We are currently unable to fetch pipeline data'), + fetchLoading: s__('Pipeline|Checking pipeline status'), + pipelineInfo: s__( + `Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`, + ), +}; + +export default { + i18n, + components: { + CiIcon, + GlIcon, + GlLink, + GlLoadingIcon, + GlSprintf, + }, + inject: ['projectFullPath'], + apollo: { + commitSha: { + query: getCommitSha, + }, + pipeline: { + query: getPipelineQuery, + variables() { + return { + fullPath: this.projectFullPath, + sha: this.commitSha, + }; + }, + update: (data) => { + const { id, commitPath = '', shortSha = '', detailedStatus = {} } = + data.project?.pipeline || {}; + + return { + id, + commitPath, + shortSha, + detailedStatus, + }; + }, + error() { + this.hasError = true; + }, + pollInterval: POLL_INTERVAL, + }, + }, + data() { + return { + hasError: false, + }; + }, + computed: { + hasPipelineData() { + return Boolean(this.$apollo.queries.pipeline?.id); + }, + isQueryLoading() { + return this.$apollo.queries.pipeline.loading && !this.hasPipelineData; + }, + status() { + return this.pipeline.detailedStatus; + }, + pipelineId() { + return getIdFromGraphQLId(this.pipeline.id); + }, + }, +}; +</script> + +<template> + <div class="gl-white-space-nowrap gl-max-w-full"> + <template v-if="isQueryLoading"> + <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" /> + <span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span> + </template> + <template v-else-if="hasError"> + <gl-icon class="gl-mr-auto" name="warning-solid" /> + <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> + </template> + <template v-else> + <a :href="status.detailsPath" class="gl-mr-auto"> + <ci-icon :status="status" :size="18" /> + </a> + <span class="gl-font-weight-bold"> + <gl-sprintf :message="$options.i18n.pipelineInfo"> + <template #id="{ content }"> + <gl-link + :href="status.detailsPath" + class="pipeline-id gl-font-weight-normal pipeline-number" + target="_blank" + data-testid="pipeline-id" + > + {{ content }}{{ pipelineId }}</gl-link + > + </template> + <template #status>{{ status.text }}</template> + <template #commit> + <gl-link + :href="pipeline.commitPath" + class="commit-sha gl-font-weight-normal" + target="_blank" + data-testid="pipeline-commit" + > + {{ pipeline.shortSha }} + </gl-link> + </template> + </gl-sprintf> + </span> + </template> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql new file mode 100644 index 00000000000..7cc7f92fb60 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql @@ -0,0 +1,17 @@ +query getPipeline($fullPath: ID!, $sha: String!) { + project(fullPath: $fullPath) @client { + pipeline(sha: $sha) { + commitPath + id + iid + shortSha + status + detailedStatus { + detailsPath + icon + group + text + } + } + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index 81e75c32846..13f6200693b 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -11,6 +11,29 @@ export const resolvers = { }), }; }, + + /* eslint-disable @gitlab/require-i18n-strings */ + project() { + return { + __typename: 'Project', + pipeline: { + __typename: 'Pipeline', + commitPath: `/-/commit/aabbccdd`, + id: 'gid://gitlab/Ci::Pipeline/118', + iid: '28', + shortSha: 'aabbccdd', + status: 'SUCCESS', + detailedStatus: { + __typename: 'DetailedStatus', + detailsPath: '/root/sample-ci-project/-/pipelines/118"', + group: 'success', + icon: 'status_success', + text: 'passed', + }, + }, + }; + }, + /* eslint-enable @gitlab/require-i18n-strings */ }, Mutation: { lintCI: (_, { endpoint, content, dry_run }) => { diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index 5070971c563..bd112697b49 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -17,6 +17,7 @@ import { GlLoadingIcon, GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { uniqueId } from 'lodash'; import Vue from 'vue'; import axios from '~/lib/utils/axios_utils'; @@ -24,7 +25,6 @@ import { backOff } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__, __, n__ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; import { VARIABLE_TYPE, FILE_TYPE, CONFIG_VARIABLES_TIMEOUT } from '../constants'; export default { diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 93156d5d05b..0d81a383009 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -15,14 +15,19 @@ export default { StageColumnComponent, }, props: { + pipeline: { + type: Object, + required: true, + }, isLinkedPipeline: { type: Boolean, required: false, default: false, }, - pipeline: { - type: Object, - required: true, + metricsPath: { + type: String, + required: false, + default: '', }, type: { type: String, @@ -66,6 +71,12 @@ export default { hasUpstreamPipelines() { return Boolean(this.pipeline?.upstream?.length > 0); }, + metricsConfig() { + return { + path: this.metricsPath, + collectMetrics: true, + }; + }, // The show downstream check prevents showing redundant linked columns showDownstreamPipelines() { return ( @@ -145,6 +156,7 @@ export default { :container-id="containerId" :container-measurements="measurements" :highlighted-job="hoveredJobName" + :metrics-config="metricsConfig" default-link-color="gl-stroke-transparent" @error="onError" @highlightedJobsChange="updateHighlightedJobs" diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index f596333237d..0e6d268fc19 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -2,7 +2,7 @@ import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import { __ } from '~/locale'; -import { DEFAULT, LOAD_FAILURE } from '../../constants'; +import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; import PipelineGraph from './graph_component.vue'; import { unwrapPipelineData, toggleQueryPollingByVisibility, reportToSentry } from './utils'; @@ -14,6 +14,9 @@ export default { PipelineGraph, }, inject: { + metricsPath: { + default: '', + }, pipelineIid: { default: '', }, @@ -29,6 +32,7 @@ export default { }; }, errorTexts: { + [DRAW_FAILURE]: __('An error occurred while drawing job relationship links.'), [LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'), [DEFAULT]: __('An unknown error occurred while loading this graph.'), }, @@ -53,6 +57,11 @@ export default { computed: { alert() { switch (this.alertType) { + case DRAW_FAILURE: + return { + text: this.$options.errorTexts[DRAW_FAILURE], + variant: 'danger', + }; case LOAD_FAILURE: return { text: this.$options.errorTexts[LOAD_FAILURE], @@ -88,8 +97,8 @@ export default { }, reportFailure(type) { this.showAlert = true; - this.failureType = type; - reportToSentry(this.$options.name, this.failureType); + this.alertType = type; + reportToSentry(this.$options.name, this.alertType); }, }, }; @@ -102,6 +111,7 @@ export default { <gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" /> <pipeline-graph v-if="pipeline" + :metrics-path="metricsPath" :pipeline="pipeline" @error="reportFailure" @refreshPipelineGraph="refreshPipelineGraph" diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js index 1a935599bfa..94807ea41b1 100644 --- a/app/assets/javascripts/pipelines/components/graph/utils.js +++ b/app/assets/javascripts/pipelines/components/graph/utils.js @@ -1,6 +1,6 @@ +import * as Sentry from '@sentry/browser'; import Visibility from 'visibilityjs'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import * as Sentry from '~/sentry/wrapper'; import { unwrapStagesWithNeeds } from '../unwrapping_utils'; const addMulti = (mainPipelineProjectPath, linkedPipeline) => { diff --git a/app/assets/javascripts/pipelines/components/graph_shared/api.js b/app/assets/javascripts/pipelines/components/graph_shared/api.js new file mode 100644 index 00000000000..04ac15ae24c --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph_shared/api.js @@ -0,0 +1,8 @@ +import axios from '~/lib/utils/axios_utils'; +import { reportToSentry } from '../graph/utils'; + +export const reportPerformance = (path, stats) => { + axios.post(path, stats).catch((err) => { + reportToSentry('links_inner_perf', `error: ${err}`); + }); +}; diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue index 289e04e02c5..84ca0bf1443 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue @@ -1,8 +1,19 @@ <script> import { isEmpty } from 'lodash'; +import { + PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, + PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, + PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + PIPELINES_DETAIL_LINK_DURATION, + PIPELINES_DETAIL_LINKS_TOTAL, + PIPELINES_DETAIL_LINKS_JOB_RATIO, +} from '~/performance/constants'; +import { performanceMarkAndMeasure } from '~/performance/utils'; import { DRAW_FAILURE } from '../../constants'; import { createJobsHash, generateJobNeedsDict } from '../../utils'; +import { reportToSentry } from '../graph/utils'; import { parseData } from '../parsing_utils'; +import { reportPerformance } from './api'; import { generateLinksData } from './drawing_utils'; export default { @@ -25,6 +36,15 @@ export default { type: Array, required: true, }, + totalGroups: { + type: Number, + required: true, + }, + metricsConfig: { + type: Object, + required: false, + default: () => ({}), + }, defaultLinkColor: { type: String, required: false, @@ -43,6 +63,9 @@ export default { }; }, computed: { + shouldCollectMetrics() { + return this.metricsConfig.collectMetrics && this.metricsConfig.path; + }, hasHighlightedJob() { return Boolean(this.highlightedJob); }, @@ -87,23 +110,70 @@ export default { this.$emit('highlightedJobsChange', jobs); }, }, + errorCaptured(err, _vm, info) { + reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); + }, mounted() { if (!isEmpty(this.pipelineData)) { this.prepareLinkData(); } }, methods: { + beginPerfMeasure() { + if (this.shouldCollectMetrics) { + performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START }); + } + }, + finishPerfMeasureAndSend() { + if (this.shouldCollectMetrics) { + performanceMarkAndMeasure({ + mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, + measures: [ + { + name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, + }, + ], + }); + } + + window.requestAnimationFrame(() => { + const duration = window.performance.getEntriesByName( + PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + )[0]?.duration; + + if (!duration) { + return; + } + + const data = { + histograms: [ + { name: PIPELINES_DETAIL_LINK_DURATION, value: duration }, + { name: PIPELINES_DETAIL_LINKS_TOTAL, value: this.links.length }, + { + name: PIPELINES_DETAIL_LINKS_JOB_RATIO, + value: this.links.length / this.totalGroups, + }, + ], + }; + + reportPerformance(this.metricsConfig.path, data); + }); + }, isLinkHighlighted(linkRef) { return this.highlightedLinks.includes(linkRef); }, prepareLinkData() { + this.beginPerfMeasure(); try { const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups); const parsedData = parseData(arrayOfJobs); this.links = generateLinksData(parsedData, this.containerId, `-${this.pipelineId}`); - } catch { + } catch (err) { this.$emit('error', DRAW_FAILURE); + reportToSentry(this.$options.name, err); } + this.finishPerfMeasureAndSend(); }, getLinkClasses(link) { return [ diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue index 1c1bc7ecb2a..baf0a4d50de 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue @@ -1,6 +1,7 @@ <script> import { GlAlert } from '@gitlab/ui'; import { __ } from '~/locale'; +import { reportToSentry } from '../graph/utils'; import LinksInner from './links_inner.vue'; export default { @@ -50,6 +51,9 @@ export default { ); }, }, + errorCaptured(err, _vm, info) { + reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); + }, methods: { dismissAlert() { this.alertDismissed = true; @@ -66,6 +70,7 @@ export default { v-if="showLinkedLayers" :container-measurements="containerMeasurements" :pipeline-data="pipelineData" + :total-groups="numGroups" v-bind="$attrs" v-on="$listeners" > diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index 8a656bb47f4..90c6acc9e6f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -1,5 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; export default { @@ -14,10 +15,6 @@ export default { GlButton, }, props: { - helpPagePath: { - type: String, - required: true, - }, emptyStateSvgPath: { type: String, required: true, @@ -27,6 +24,11 @@ export default { required: true, }, }, + computed: { + ciHelpPagePath() { + return helpPagePath('ci/quick_start/index.md'); + }, + }, }; </script> <template> @@ -47,7 +49,7 @@ export default { <div class="gl-text-center"> <gl-button - :href="helpPagePath" + :href="ciHelpPagePath" variant="info" category="primary" data-testid="get-started-pipelines" 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 823ada133d2..a61ffe8f0fc 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -1,5 +1,6 @@ <script> import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { SCHEDULE_ORIGIN } from '../../constants'; export default { @@ -26,10 +27,6 @@ export default { type: String, required: true, }, - autoDevopsHelpPath: { - type: String, - required: true, - }, }, computed: { user() { @@ -44,6 +41,12 @@ export default { this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`, ); }, + autoDevopsTagId() { + return `pipeline-url-autodevops-${this.pipeline.id}`; + }, + autoDevopsHelpPath() { + return helpPagePath('topics/autodevops/index.md'); + }, }, }; </script> @@ -103,38 +106,43 @@ export default { data-testid="pipeline-url-failure" >{{ __('error') }}</gl-badge > - <gl-link - v-if="pipeline.flags.auto_devops" - :id="`pipeline-url-autodevops-${pipeline.id}`" - tabindex="0" - data-testid="pipeline-url-autodevops" - role="button" - ><gl-badge variant="info" size="sm">{{ __('Auto DevOps') }}</gl-badge></gl-link - > - <gl-popover - :target="`pipeline-url-autodevops-${pipeline.id}`" - triggers="focus" - placement="top" - > - <template #title> - <div class="gl-font-weight-normal gl-line-height-normal"> - <gl-sprintf - :message=" - __( - 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}', - ) - " - > - <template #strong="{ content }"> - <b>{{ content }}</b> - </template> - </gl-sprintf> - </div> - </template> - <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow">{{ - __('Learn more about Auto DevOps') - }}</gl-link> - </gl-popover> + <template v-if="pipeline.flags.auto_devops"> + <gl-link + :id="autoDevopsTagId" + tabindex="0" + data-testid="pipeline-url-autodevops" + role="button" + > + <gl-badge variant="info" size="sm"> + {{ __('Auto DevOps') }} + </gl-badge> + </gl-link> + <gl-popover :target="autoDevopsTagId" triggers="focus" placement="top"> + <template #title> + <div class="gl-font-weight-normal gl-line-height-normal"> + <gl-sprintf + :message=" + __( + 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}', + ) + " + > + <template #strong="{ content }"> + <b>{{ content }}</b> + </template> + </gl-sprintf> + </div> + </template> + <gl-link + :href="autoDevopsHelpPath" + data-testid="pipeline-url-autodevops-link" + target="_blank" + > + {{ __('Learn more about Auto DevOps') }} + </gl-link> + </gl-popover> + </template> + <gl-badge v-if="pipeline.flags.stuck" variant="warning" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 48009a9fcb8..19d93e7d083 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -52,10 +52,6 @@ export default { required: false, default: '', }, - helpPagePath: { - type: String, - required: true, - }, emptyStateSvgPath: { type: String, required: true, @@ -68,10 +64,6 @@ export default { type: String, required: true, }, - autoDevopsHelpPath: { - type: String, - required: true, - }, hasGitlabCi: { type: Boolean, required: true, @@ -337,7 +329,6 @@ export default { <empty-state v-else-if="stateToRender === $options.stateMap.emptyState" - :help-page-path="helpPagePath" :empty-state-svg-path="emptyStateSvgPath" :can-set-ci="canCreatePipeline" /> @@ -362,7 +353,6 @@ export default { :pipelines="state.pipelines" :pipeline-schedule-url="pipelineScheduleUrl" :update-graph-dropdown="updateGraphDropdown" - :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" /> </div> 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 24c67184e56..fdc8c3e1866 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -32,10 +32,6 @@ export default { required: false, default: false, }, - autoDevopsHelpPath: { - type: String, - required: true, - }, viewType: { type: String, required: true, @@ -102,7 +98,6 @@ export default { :pipeline="model" :pipeline-schedule-url="pipelineScheduleUrl" :update-graph-dropdown="updateGraphDropdown" - :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" :canceling-pipeline="cancelingPipeline" /> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue index 572abe2a24a..68deca313eb 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue @@ -47,10 +47,6 @@ export default { required: false, default: false, }, - autoDevopsHelpPath: { - type: String, - required: true, - }, viewType: { type: String, required: true, @@ -194,11 +190,7 @@ export default { </div> </div> - <pipeline-url - :pipeline="pipeline" - :pipeline-schedule-url="pipelineScheduleUrl" - :auto-devops-help-path="autoDevopsHelpPath" - /> + <pipeline-url :pipeline="pipeline" :pipeline-schedule-url="pipelineScheduleUrl" /> <pipeline-triggerer :pipeline="pipeline" /> <div class="table-section section-wrap section-20"> diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index f837851e5c1..e287f188523 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -93,8 +93,13 @@ export default async function initPipelineDetailsBundle() { /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph' ); - const { pipelineProjectPath, pipelineIid } = dataset; - createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, pipelineProjectPath, pipelineIid); + const { metricsPath, pipelineProjectPath, pipelineIid } = dataset; + createPipelinesDetailApp( + SELECTORS.PIPELINE_GRAPH, + pipelineProjectPath, + pipelineIid, + metricsPath, + ); } catch { Flash(__('An error occurred while loading the pipeline.')); } diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js index 55f3731a3ca..a5e2c792ffd 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_graph.js +++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js @@ -16,7 +16,7 @@ const apolloProvider = new VueApollo({ ), }); -const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) => { +const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid, metricsPath) => { // eslint-disable-next-line no-new new Vue({ el: selector, @@ -25,6 +25,7 @@ const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) => }, apolloProvider, provide: { + metricsPath, pipelineProjectPath, pipelineIid, dataMethod: GRAPHQL, diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index 7bcc51e18e5..0e2e9785956 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -23,11 +23,9 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { const { endpoint, pipelineScheduleUrl, - helpPagePath, emptyStateSvgPath, errorStateSvgPath, noPipelinesSvgPath, - autoDevopsHelpPath, newPipelinePath, canCreatePipeline, hasGitlabCi, @@ -56,11 +54,9 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { store: this.store, endpoint, pipelineScheduleUrl, - helpPagePath, emptyStateSvgPath, errorStateSvgPath, noPipelinesSvgPath, - autoDevopsHelpPath, newPipelinePath, canCreatePipeline: parseBoolean(canCreatePipeline), hasGitlabCi: parseBoolean(hasGitlabCi), diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index a7332b81b9f..dad2c18fb18 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -95,6 +95,7 @@ export default class Profile { updateHeaderAvatar() { $('.header-user-avatar').attr('src', this.avatarGlCrop.dataURL); + $('.js-sidebar-user-avatar').attr('src', this.avatarGlCrop.dataURL); } setRepoRadio() { diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index 72d4f0c31e5..741dc20b1f1 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -1,8 +1,8 @@ +import * as Sentry from '@sentry/browser'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; import * as types from './mutation_types'; export default { diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue index 05bd0f1370b..bee93e434d6 100644 --- a/app/assets/javascripts/projects/compare/components/app.vue +++ b/app/assets/javascripts/projects/compare/components/app.vue @@ -69,21 +69,21 @@ export default { <gl-button category="primary" variant="success" class="gl-ml-3" @click="onSubmit"> {{ s__('CompareRevisions|Compare') }} </gl-button> - <a + <gl-button v-if="projectMergeRequestPath" :href="projectMergeRequestPath" data-testid="projectMrButton" class="btn btn-default gl-button gl-ml-3" > {{ s__('CompareRevisions|View open merge request') }} - </a> - <a + </gl-button> + <gl-button v-else-if="createMrPath" :href="createMrPath" data-testid="createMrButton" class="btn btn-default gl-button gl-ml-3" > {{ s__('CompareRevisions|Create merge request') }} - </a> + </gl-button> </form> </template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue index 733f833d51a..09ca1fbe6c6 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue @@ -252,10 +252,10 @@ export default { }, errorTexts: { [LOAD_ANALYTICS_FAILURE]: s__( - 'PipelineCharts|An error has ocurred when retrieving the analytics data', + 'PipelineCharts|An error has occurred when retrieving the analytics data', ), [LOAD_PIPELINES_FAILURE]: s__( - 'PipelineCharts|An error has ocurred when retrieving the pipelines data', + 'PipelineCharts|An error has occurred when retrieving the pipelines data', ), [PARSE_FAILURE]: s__('PipelineCharts|There was an error parsing the data for the charts.'), [DEFAULT]: s__('PipelineCharts|An unknown error occurred while processing CI/CD analytics.'), diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index 9b3c0dd2755..fb00f58abae 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -95,7 +95,7 @@ export default { }) .catch((err) => { this.showAlert( - sprintf(__('An error occured while saving changes: %{error}'), { + sprintf(__('An error occurred while saving changes: %{error}'), { error: err?.response?.data?.message, }), ); diff --git a/app/assets/javascripts/projects/upload_file_experiment.js b/app/assets/javascripts/projects/upload_file_experiment.js new file mode 100644 index 00000000000..c2a68043489 --- /dev/null +++ b/app/assets/javascripts/projects/upload_file_experiment.js @@ -0,0 +1,21 @@ +import ExperimentTracking from '~/experiment_tracking'; + +function trackEvent(eventName) { + const Tracking = new ExperimentTracking('empty_repo_upload', { label: 'blob-upload-modal' }); + + Tracking.event(eventName); +} + +export function initUploadFileTrigger() { + const uploadFileTriggerEl = document.querySelector('.js-upload-file-experiment-trigger'); + + if (uploadFileTriggerEl) { + uploadFileTriggerEl.addEventListener('click', () => { + trackEvent('click_upload_modal_trigger'); + }); + } +} + +export function trackUploadFileFormSubmitted() { + trackEvent('click_upload_modal_form_submit'); +} diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue index 033b8798473..466fbab6841 100644 --- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -86,7 +86,7 @@ export default { } if (!report.name) { - return s__('Reports|An error occured while loading report'); + return s__('Reports|An error occurred while loading report'); } return reportTextBuilder(name, summary); diff --git a/app/assets/javascripts/security_configuration/components/configuration_table.vue b/app/assets/javascripts/security_configuration/components/configuration_table.vue index 9475cc1781f..d1ac7190c37 100644 --- a/app/assets/javascripts/security_configuration/components/configuration_table.vue +++ b/app/assets/javascripts/security_configuration/components/configuration_table.vue @@ -4,6 +4,7 @@ import { s__, sprintf } from '~/locale'; import { REPORT_TYPE_SAST, REPORT_TYPE_DAST, + REPORT_TYPE_DAST_PROFILES, REPORT_TYPE_DEPENDENCY_SCANNING, REPORT_TYPE_CONTAINER_SCANNING, REPORT_TYPE_COVERAGE_FUZZING, @@ -40,6 +41,7 @@ export default { const COMPONENTS = { [REPORT_TYPE_SAST]: ManageSast, [REPORT_TYPE_DAST]: Upgrade, + [REPORT_TYPE_DAST_PROFILES]: Upgrade, [REPORT_TYPE_DEPENDENCY_SCANNING]: Upgrade, [REPORT_TYPE_CONTAINER_SCANNING]: Upgrade, [REPORT_TYPE_COVERAGE_FUZZING]: Upgrade, @@ -81,7 +83,8 @@ export default { {{ item.description }} <gl-link target="_blank" - :href="item.link" + data-testid="help-link" + :href="item.helpPath" :aria-label="getFeatureDocumentationLinkLabel(item)" > {{ s__('SecurityConfiguration|More information') }} diff --git a/app/assets/javascripts/security_configuration/components/features_constants.js b/app/assets/javascripts/security_configuration/components/features_constants.js index d846a2761d9..c0eef0611a0 100644 --- a/app/assets/javascripts/security_configuration/components/features_constants.js +++ b/app/assets/javascripts/security_configuration/components/features_constants.js @@ -4,6 +4,7 @@ import { s__ } from '~/locale'; import { REPORT_TYPE_SAST, REPORT_TYPE_DAST, + REPORT_TYPE_DAST_PROFILES, REPORT_TYPE_SECRET_DETECTION, REPORT_TYPE_DEPENDENCY_SCANNING, REPORT_TYPE_CONTAINER_SCANNING, @@ -22,6 +23,10 @@ export const DAST_NAME = s__('Dynamic Application Security Testing (DAST)'); export const DAST_DESCRIPTION = s__('Analyze a review version of your web application.'); export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index'); +export const DAST_PROFILES_NAME = s__('DAST Scans'); +export const DAST_PROFILES_DESCRIPTION = s__('Analyze a review version of your web application.'); +export const DAST_PROFILES_HELP_PATH = helpPagePath('user/application_security/dast/index'); + export const SECRET_DETECTION_NAME = s__('Secret Detection'); export const SECRET_DETECTION_DESCRIPTION = s__( 'Analyze your source code and git history for secrets.', @@ -80,6 +85,12 @@ export const features = [ type: REPORT_TYPE_DAST, }, { + name: DAST_PROFILES_NAME, + description: DAST_PROFILES_DESCRIPTION, + helpPath: DAST_PROFILES_HELP_PATH, + type: REPORT_TYPE_DAST_PROFILES, + }, + { name: SECRET_DETECTION_NAME, description: SECRET_DETECTION_DESCRIPTION, helpPath: SECRET_DETECTION_HELP_PATH, diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js index 4277ffec545..bc3b2f16a6a 100644 --- a/app/assets/javascripts/sentry/sentry_config.js +++ b/app/assets/javascripts/sentry/sentry_config.js @@ -1,6 +1,6 @@ +import * as Sentry from '@sentry/browser'; import $ from 'jquery'; import { __ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; const IGNORE_ERRORS = [ // Random plugins/extensions diff --git a/app/assets/javascripts/sentry/wrapper.js b/app/assets/javascripts/sentry/wrapper.js deleted file mode 100644 index 24039e6141c..00000000000 --- a/app/assets/javascripts/sentry/wrapper.js +++ /dev/null @@ -1,26 +0,0 @@ -// Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179 -// export * from '@sentry/browser'; - -export function init(...args) { - return args; -} - -export function setUser(...args) { - return args; -} - -export function captureException(...args) { - return args; -} - -export function captureMessage(...args) { - return args; -} - -export function withScope(fn) { - fn({ - setTag(...args) { - return args; - }, - }); -} diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index 9b06c20a6f3..c0424dc2873 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -122,6 +122,8 @@ export default { :value="subscribed" class="hide-collapsed" data-testid="subscription-toggle" + :label="__('Notifications')" + label-position="hidden" @change="toggleSubscription" /> </div> 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 e0f60b9af08..d1a5685fdd3 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue @@ -1,10 +1,14 @@ <script> /* eslint-disable vue/no-v-html */ +import { GlButton } from '@gitlab/ui'; import { joinPaths } from '~/lib/utils/url_utility'; import { sprintf, s__ } from '../../../locale'; export default { name: 'TimeTrackingHelpState', + components: { + GlButton, + }, computed: { href() { return joinPaths(gon.relative_url_root || '', '/help/user/project/time_tracking.md'); @@ -40,7 +44,7 @@ export default { <p>{{ __('Quick actions can be used in the issues description and comment boxes.') }}</p> <p v-html="estimateText"></p> <p v-html="spendText"></p> - <a :href="href" class="btn btn-default learn-more-button"> {{ __('Learn more') }} </a> + <gl-button :href="href">{{ __('Learn more') }}</gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js index 4cabd943e22..5fb20b00705 100644 --- a/app/assets/javascripts/static_site_editor/constants.js +++ b/app/assets/javascripts/static_site_editor/constants.js @@ -12,7 +12,7 @@ export const SUBMIT_CHANGES_MERGE_REQUEST_ERROR = s__( 'StaticSiteEditor|Could not create merge request.', ); export const LOAD_CONTENT_ERROR = __( - 'An error ocurred while loading your content. Please try again.', + 'An error occurred while loading your content. Please try again.', ); export const DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE = s__( diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js index 5d82d56f4ba..db6fca4bd05 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking.js @@ -1,5 +1,13 @@ import { omitBy, isUndefined } from 'lodash'; +export const STANDARD_CONTEXT = { + schema: 'iglu:com.gitlab/gitlab_standard/jsonschema/1-0-3', + data: { + environment: process.env.NODE_ENV, + source: 'gitlab-javascript', + }, +}; + const DEFAULT_SNOWPLOW_OPTIONS = { namespace: 'gl', hostname: window.location.hostname, @@ -67,8 +75,13 @@ export default class Tracking { // eslint-disable-next-line @gitlab/require-i18n-strings if (!category) throw new Error('Tracking: no category provided for tracking.'); - const { label, property, value, context } = data; - const contexts = context ? [context] : undefined; + const { label, property, value } = data; + const contexts = [STANDARD_CONTEXT]; + + if (data.context) { + contexts.push(data.context); + } + return window.snowplow('trackStructEvent', category, action, label, property, value, contexts); } @@ -134,7 +147,8 @@ export function initDefaultTrackers() { if (!Tracking.enabled()) return; window.snowplow('enableActivityTracking', 30, 30); - window.snowplow('trackPageView'); // must be after enableActivityTracking + // must be after enableActivityTracking + window.snowplow('trackPageView', null, [STANDARD_CONTEXT]); if (window.snowplowOptions.formTracking) window.snowplow('enableFormTracking'); if (window.snowplowOptions.linkClickTracking) window.snowplow('enableLinkClickTracking'); diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index c18f4fb46cc..682932f6750 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -59,11 +59,33 @@ const populateUserInfo = (user) => { }; const initializedPopovers = new Map(); +let domObservedForChanges = false; -export default (elements = document.querySelectorAll('.js-user-link')) => { +const addPopoversToModifiedTree = new MutationObserver(() => { + const userLinks = document?.querySelectorAll('.js-user-link, .gfm-project_member'); + + if (userLinks) { + addPopovers(userLinks); /* eslint-disable-line no-use-before-define */ + } +}); + +function observeBody() { + if (!domObservedForChanges) { + addPopoversToModifiedTree.observe(document.body, { + subtree: true, + childList: true, + }); + + domObservedForChanges = true; + } +} + +export default function addPopovers(elements = document.querySelectorAll('.js-user-link')) { const userLinks = Array.from(elements); const UserPopoverComponent = Vue.extend(UserPopover); + observeBody(); + return userLinks .filter(({ dataset }) => dataset.user || dataset.userId) .map((el) => { @@ -105,4 +127,4 @@ export default (elements = document.querySelectorAll('.js-user-link')) => { return renderedPopover; }); -}; +} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 2335e2984e4..31414418e04 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -162,7 +162,7 @@ export default { <rect x="250" y="7" width="84" height="16" rx="4" /> </gl-skeleton-loader> </div> - <div v-else class="media-body space-children"> + <div v-else class="media-body space-children gl-display-flex gl-align-items-center"> <span v-if="shouldBeRebased" class="bold"> {{ s__(`mrWidget|Fast-forward merge is not possible. diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue index bcea7ca654e..fa1cb311b34 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue @@ -11,12 +11,12 @@ import { GlButton, GlSafeHtmlDirective, } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import { fetchPolicies } from '~/lib/graphql'; import { toggleContainerClasses } from '~/lib/utils/dom_utils'; import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; import Tracking from '~/tracking'; import initUserPopovers from '~/user_popovers'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; @@ -368,7 +368,7 @@ export default { <alert-details-table :alert="alert" :loading="loading" /> </gl-tab> <gl-tab - v-if="isThreatMonitoringPage" + v-if="!isThreatMonitoringPage" :data-testid="$options.tabsConfig[1].id" :title="$options.tabsConfig[1].title" > diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue index dd4faa03c00..9d5006564ef 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue @@ -1,7 +1,7 @@ <script> +import * as Sentry from '@sentry/browser'; import Vue from 'vue'; import Vuex from 'vuex'; -import * as Sentry from '~/sentry/wrapper'; Vue.use(Vuex); diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue index 31094b985a2..92ae4575c52 100644 --- a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue +++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue @@ -5,6 +5,11 @@ import { __ } from '~/locale'; export default { components: { GlButton }, props: { + slideAnimated: { + type: Boolean, + default: true, + required: false, + }, defaultExpanded: { type: Boolean, default: false, @@ -28,7 +33,7 @@ export default { </script> <template> - <section class="settings no-animate" :class="{ expanded }"> + <section class="settings" :class="{ 'no-animate': !slideAnimated, expanded }"> <div class="settings-header"> <h4><slot name="title"></slot></h4> <gl-button @click="sectionExpanded = !sectionExpanded"> diff --git a/app/assets/javascripts/vue_shared/components/tabs/tab.vue b/app/assets/javascripts/vue_shared/components/tabs/tab.vue deleted file mode 100644 index d24c27cfcc3..00000000000 --- a/app/assets/javascripts/vue_shared/components/tabs/tab.vue +++ /dev/null @@ -1,47 +0,0 @@ -<script> -export default { - props: { - title: { - type: String, - required: false, - default: '', - }, - active: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - // props can't be updated, so we map it to data where we can - localActive: this.active, - }; - }, - watch: { - active() { - this.localActive = this.active; - }, - }, - created() { - this.isTab = true; - }, - updated() { - if (this.$parent) { - this.$parent.$forceUpdate(); - } - }, -}; -</script> - -<template> - <div - :class="{ - active: localActive, - }" - class="tab-pane" - role="tabpanel" - > - <slot></slot> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/tabs/tabs.js b/app/assets/javascripts/vue_shared/components/tabs/tabs.js deleted file mode 100644 index 233df96a520..00000000000 --- a/app/assets/javascripts/vue_shared/components/tabs/tabs.js +++ /dev/null @@ -1,76 +0,0 @@ -export default { - props: { - stopPropagation: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - currentIndex: 0, - tabs: [], - }; - }, - mounted() { - this.updateTabs(); - }, - methods: { - updateTabs() { - this.tabs = this.$children.filter((child) => child.isTab); - this.currentIndex = this.tabs.findIndex((tab) => tab.localActive); - }, - setTab(e, index) { - if (this.stopPropagation) { - e.stopPropagation(); - e.preventDefault(); - } - - this.tabs[this.currentIndex].localActive = false; - this.tabs[index].localActive = true; - - this.currentIndex = index; - }, - }, - render(h) { - const navItems = this.tabs.map((tab, i) => - h( - 'li', - { - key: i, - }, - [ - h( - 'a', - { - class: tab.localActive ? 'active' : null, - attrs: { - href: '#', - }, - on: { - click: (e) => this.setTab(e, i), - }, - }, - tab.$slots.title || tab.title, - ), - ], - ), - ); - const nav = h( - 'ul', - { - class: 'nav-links tab-links', - }, - [navItems], - ); - const content = h( - 'div', - { - class: ['tab-content'], - }, - [this.$slots.default], - ); - - return h('div', {}, [[nav], content]); - }, -}; diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue index 8aa6e29adf1..c5fdb5fc242 100644 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue @@ -1,11 +1,11 @@ <script> +import { GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { isFunction } from 'lodash'; import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; -import tooltip from '../directives/tooltip'; export default { directives: { - tooltip, + GlTooltip, }, props: { title: { @@ -59,9 +59,8 @@ export default { <template> <span v-if="showTooltip" - v-tooltip + v-gl-tooltip="{ placement }" :title="title" - :data-placement="placement" class="js-show-tooltip gl-min-w-0" > <slot></slot> 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 5a08e992084..afb1ea702fa 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 @@ -36,6 +36,11 @@ export default { required: false, default: () => [VALID_IMAGE_FILE_MIMETYPE.mimetype], }, + singleFileSelection: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -79,7 +84,7 @@ export default { return; } - this.$emit('change', files); + this.$emit('change', this.singleFileSelection ? files[0] : files); }, ondragenter(e) { this.dragCounter += 1; @@ -92,7 +97,7 @@ export default { this.$refs.fileUpload.click(); }, onFileInputChange(e) { - this.$emit('change', e.target.files); + this.$emit('change', this.singleFileSelection ? e.target.files[0] : e.target.files); }, }, }; @@ -119,9 +124,15 @@ export default { data-testid="dropzone-area" > <gl-icon name="upload" :size="iconStyles.size" :class="iconStyles.class" /> - <p class="gl-mb-0"> + <p class="gl-mb-0" data-testid="upload-text"> <slot name="upload-text" :openFileUpload="openFileUpload"> - <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} files to attach')"> + <gl-sprintf + :message=" + singleFileSelection + ? __('Drop or %{linkStart}upload%{linkEnd} file to attach') + : __('Drop or %{linkStart}upload%{linkEnd} files to attach') + " + > <template #link="{ content }"> <gl-link @click.stop="openFileUpload"> {{ content }} @@ -139,7 +150,7 @@ export default { name="upload_file" :accept="validFileMimetypes" class="hide" - multiple + :multiple="!singleFileSelection" @change="onFileInputChange" /> </slot> diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js deleted file mode 100644 index 0eb505bfce8..00000000000 --- a/app/assets/javascripts/vue_shared/directives/tooltip.js +++ /dev/null @@ -1,35 +0,0 @@ -import $ from 'jquery'; -import '~/commons/bootstrap'; -import { parseBoolean } from '~/lib/utils/common_utils'; - -export default { - bind(el) { - const glTooltipDelay = localStorage.getItem('gl-tooltip-delay'); - const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0; - - $(el).tooltip({ - trigger: 'hover', - delay, - // By default, sanitize is run even if there is no `html` or `template` present - // so let's optimize to only run this when necessary. - // https://github.com/twbs/bootstrap/blob/c5966de27395a407f9a3d20d0eb2ff8e8fb7b564/js/src/tooltip.js#L716 - sanitize: parseBoolean(el.dataset.html) || Boolean(el.dataset.template), - }); - }, - - componentUpdated(el) { - $(el).tooltip('_fixTitle'); - - // update visible tooltips - const tooltipInstance = $(el).data('bs.tooltip'); - const tip = tooltipInstance.getTipElement(); - tooltipInstance.setElementContent( - $(tip.querySelectorAll('.tooltip-inner')), - tooltipInstance.getTitle(), - ); - }, - - unbind(el) { - $(el).tooltip('dispose'); - }, -}; diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js index aac5a5c1def..56a8853412d 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -18,6 +18,7 @@ export const REPORT_FILE_TYPES = { */ export const REPORT_TYPE_SAST = 'sast'; export const REPORT_TYPE_DAST = 'dast'; +export const REPORT_TYPE_DAST_PROFILES = 'dast_profiles'; export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection'; export const REPORT_TYPE_DEPENDENCY_SCANNING = 'dependency_scanning'; export const REPORT_TYPE_CONTAINER_SCANNING = 'container_scanning'; |