diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-05-19 10:33:21 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-05-19 10:33:21 +0300 |
commit | 36a59d088eca61b834191dacea009677a96c052f (patch) | |
tree | e4f33972dab5d8ef79e3944a9f403035fceea43f /app/assets/javascripts | |
parent | a1761f15ec2cae7c7f7bbda39a75494add0dfd6f (diff) |
Add latest changes from gitlab-org/gitlab@15-0-stable-eev15.0.0-rc42
Diffstat (limited to 'app/assets/javascripts')
664 files changed, 10561 insertions, 7487 deletions
diff --git a/app/assets/javascripts/access_tokens/components/projects_token_selector.vue b/app/assets/javascripts/access_tokens/components/projects_token_selector.vue index a746f62b3a1..4843c52fcbb 100644 --- a/app/assets/javascripts/access_tokens/components/projects_token_selector.vue +++ b/app/assets/javascripts/access_tokens/components/projects_token_selector.vue @@ -148,7 +148,7 @@ export default { </template> <template #dropdown-footer> <gl-intersection-observer v-if="projects.pageInfo.hasNextPage" @appear="loadMoreProjects"> - <gl-loading-icon v-if="isLoadingMoreProjects" size="md" /> + <gl-loading-icon v-if="isLoadingMoreProjects" class="gl-mb-3" size="sm" /> </gl-intersection-observer> </template> </gl-token-selector> diff --git a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue new file mode 100644 index 00000000000..7f6e5dc4f35 --- /dev/null +++ b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue @@ -0,0 +1,51 @@ +<script> +import { GlListbox } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; + +export default { + name: 'BackgroundMigrationsDatabaseListbox', + i18n: { + database: s__('BackgroundMigrations|Database'), + }, + components: { + GlListbox, + }, + props: { + databases: { + type: Array, + required: true, + }, + selectedDatabase: { + type: String, + required: true, + }, + }, + data() { + return { + selected: this.selectedDatabase, + }; + }, + methods: { + selectDatabase(database) { + visitUrl(setUrlParams({ database })); + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center" data-testid="database-listbox"> + <label id="label" class="gl-font-weight-bold gl-mr-4 gl-mb-0">{{ + $options.i18n.database + }}</label> + <gl-listbox + v-model="selected" + :items="databases" + right + :toggle-text="selectedDatabase" + aria-labelledby="label" + @select="selectDatabase" + /> + </div> +</template> diff --git a/app/assets/javascripts/admin/background_migrations/index.js b/app/assets/javascripts/admin/background_migrations/index.js new file mode 100644 index 00000000000..4ddd8f17c9a --- /dev/null +++ b/app/assets/javascripts/admin/background_migrations/index.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import * as Sentry from '@sentry/browser'; +import Translate from '~/vue_shared/translate'; +import BackgroundMigrationsDatabaseListbox from './components/database_listbox.vue'; + +Vue.use(Translate); + +export const initBackgroundMigrationsApp = () => { + const el = document.getElementById('js-database-listbox'); + + if (!el) { + return false; + } + + const { selectedDatabase } = el.dataset; + let { databases } = el.dataset; + + try { + databases = JSON.parse(databases).map((database) => ({ + value: database, + text: database, + })); + } catch (e) { + Sentry.captureException(e); + } + + return new Vue({ + el, + render(createElement) { + return createElement(BackgroundMigrationsDatabaseListbox, { + props: { + databases, + selectedDatabase, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/admin/statistics_panel/components/app.vue b/app/assets/javascripts/admin/statistics_panel/components/app.vue index 1f0db422807..f250bdae4f5 100644 --- a/app/assets/javascripts/admin/statistics_panel/components/app.vue +++ b/app/assets/javascripts/admin/statistics_panel/components/app.vue @@ -29,7 +29,7 @@ export default { <div class="gl-card"> <div class="gl-card-body"> <h4>{{ __('Statistics') }}</h4> - <gl-loading-icon v-if="isLoading" size="md" class="my-3" /> + <gl-loading-icon v-if="isLoading" size="lg" class="my-3" /> <template v-else> <p v-for="statistic in getStatistics(statisticsLabels)" diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index 929f5d10956..37a6ea16018 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -178,8 +178,8 @@ export default { serverErrorMessage: '', isErrorAlertDismissed: false, sort: 'STARTED_AT_DESC', - statusFilter: [], - filteredByStatus: '', + statusFilter: ALERTS_STATUS_TABS[0].filters, + filteredByStatus: ALERTS_STATUS_TABS[0].status, alerts: {}, alertsCount: {}, sortBy: 'startedAt', @@ -283,13 +283,17 @@ export default { <paginated-table-with-search-and-tabs :show-error-msg="showErrorMsg" :i18n="$options.i18n" - :items="alerts.list || []" + :items=" + alerts.list || [] /* eslint-disable-line @gitlab/vue-no-new-non-primitive-in-template */ + " :page-info="alerts.pageInfo" :items-count="alertsCount" :status-tabs="$options.statusTabs" :track-views-options="$options.trackAlertListViewsOptions" :server-error-message="serverErrorMessage" - :filter-search-tokens="['assignee_username']" + :filter-search-tokens="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ + 'assignee_username', + ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" filter-search-key="alerts" @page-changed="pageChanged" @tabs-changed="statusChanged" @@ -305,7 +309,11 @@ export default { <template #table> <gl-table class="alert-management-table" - :items="alerts ? alerts.list : []" + :items=" + alerts + ? alerts.list + : [] /* eslint-disable-line @gitlab/vue-no-new-non-primitive-in-template */ + " :fields="$options.fields" :show-empty="true" :busy="loading" diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue index 902bad780ad..1f970ef1846 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -196,7 +196,7 @@ export default { .then( ({ data: { httpIntegrationResetToken, prometheusIntegrationResetToken } = {} } = {}) => { const [error] = - httpIntegrationResetToken?.errors || prometheusIntegrationResetToken?.errors; + httpIntegrationResetToken?.errors || prometheusIntegrationResetToken.errors; if (error) { return createFlash({ message: error }); } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql index d4f4f244759..babcdea935d 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql @@ -3,8 +3,6 @@ mutation createHttpIntegration($projectPath: ID!, $name: String!, $active: Boolean!) { httpIntegrationCreate(input: { projectPath: $projectPath, name: $name, active: $active }) { errors - # We have ID in a deeply nested fragment - # eslint-disable-next-line @graphql-eslint/require-id-when-available integration { ...HttpIntegrationItem } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql index caa258e0848..05bf8eab524 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql @@ -1,10 +1,8 @@ #import "../fragments/http_integration_item.fragment.graphql" -mutation destroyHttpIntegration($id: ID!) { +mutation destroyHttpIntegration($id: AlertManagementHttpIntegrationID!) { httpIntegrationDestroy(input: { id: $id }) { errors - # We have ID in a deeply nested fragment - # eslint-disable-next-line @graphql-eslint/require-id-when-available integration { ...HttpIntegrationItem } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql index 2f30f9abb5c..65245bfb914 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql @@ -1,10 +1,8 @@ #import "../fragments/http_integration_item.fragment.graphql" -mutation resetHttpIntegrationToken($id: ID!) { +mutation resetHttpIntegrationToken($id: AlertManagementHttpIntegrationID!) { httpIntegrationResetToken(input: { id: $id }) { errors - # We have ID in a deeply nested fragment - # eslint-disable-next-line @graphql-eslint/require-id-when-available integration { ...HttpIntegrationItem } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql index 8f34521b9fd..99179fca1f9 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql @@ -1,6 +1,6 @@ #import "../fragments/integration_item.fragment.graphql" -mutation resetPrometheusIntegrationToken($id: ID!) { +mutation resetPrometheusIntegrationToken($id: IntegrationsPrometheusID!) { prometheusIntegrationResetToken(input: { id: $id }) { errors integration { diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql index 2cf56613673..cc9e841ffb9 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql @@ -1,10 +1,12 @@ #import "../fragments/http_integration_item.fragment.graphql" -mutation updateHttpIntegration($id: ID!, $name: String!, $active: Boolean!) { +mutation updateHttpIntegration( + $id: AlertManagementHttpIntegrationID! + $name: String! + $active: Boolean! +) { httpIntegrationUpdate(input: { id: $id, name: $name, active: $active }) { errors - # We have ID in a deeply nested fragment - # eslint-disable-next-line @graphql-eslint/require-id-when-available integration { ...HttpIntegrationItem } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql index 62761730bd2..95d72e4af91 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql @@ -1,6 +1,10 @@ #import "../fragments/integration_item.fragment.graphql" -mutation updatePrometheusIntegration($id: ID!, $apiUrl: String!, $active: Boolean!) { +mutation updatePrometheusIntegration( + $id: IntegrationsPrometheusID! + $apiUrl: String! + $active: Boolean! +) { prometheusIntegrationUpdate(input: { id: $id, apiUrl: $apiUrl, active: $active }) { errors integration { diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql index 7299e6836d4..5f6ab27cae9 100644 --- a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql @@ -1,6 +1,6 @@ #import "ee_else_ce/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql" -query getHttpIntegration($projectPath: ID!, $id: ID) { +query getHttpIntegration($projectPath: ID!, $id: AlertManagementHttpIntegrationID!) { project(fullPath: $projectPath) { id alertManagementHttpIntegrations(id: $id) { diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue index b2b033de75d..b151e1605da 100644 --- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue +++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue @@ -7,6 +7,7 @@ import { GlDropdownSectionHeader, GlDropdownItem, GlSearchBoxByType, + GlTruncate, } from '@gitlab/ui'; import { debounce } from 'lodash'; import { filterBySearchTerm } from '~/analytics/shared/utils'; @@ -28,6 +29,7 @@ export default { GlDropdownSectionHeader, GlDropdownItem, GlSearchBoxByType, + GlTruncate, }, props: { groupId: { @@ -212,30 +214,29 @@ export default { <gl-dropdown ref="projectsDropdown" class="dropdown dropdown-projects" - toggle-class="gl-shadow-none" + toggle-class="gl-shadow-none gl-mb-0" :loading="loadingDefaultProjects" :show-clear-all="hasSelectedProjects" show-highlighted-items-title highlighted-items-title-class="gl-p-3" + block @clear-all.stop="onClearAll" @hide="onHide" > <template #button-content> - <gl-loading-icon v-if="loadingDefaultProjects" class="gl-mr-2" /> - <div class="gl-display-flex gl-flex-grow-1"> - <gl-avatar - v-if="isOnlyOneProjectSelected" - :src="selectedProjects[0].avatarUrl" - :entity-id="getEntityId(selectedProjects[0])" - :entity-name="selectedProjects[0].name" - :size="16" - :shape="$options.AVATAR_SHAPE_OPTION_RECT" - :alt="selectedProjects[0].name" - class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2" - /> - {{ selectedProjectsLabel }} - </div> - <gl-icon class="gl-ml-2" name="chevron-down" /> + <gl-loading-icon v-if="loadingDefaultProjects" class="gl-mr-2 gl-flex-shrink-0" /> + <gl-avatar + v-if="isOnlyOneProjectSelected" + :src="selectedProjects[0].avatarUrl" + :entity-id="getEntityId(selectedProjects[0])" + :entity-name="selectedProjects[0].name" + :size="16" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :alt="selectedProjects[0].name" + class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2 gl-flex-shrink-0" + /> + <gl-truncate :text="selectedProjectsLabel" class="gl-min-w-0 gl-flex-grow-1" /> + <gl-icon class="gl-ml-2 gl-flex-shrink-0" name="chevron-down" /> </template> <template #header> <gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header> diff --git a/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue index 1a3544e7677..6ac1bce4032 100644 --- a/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue +++ b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue @@ -1,6 +1,6 @@ <script> import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; -import { flatten, isEqual } from 'lodash'; +import { flatten, isEqual, keyBy } from 'lodash'; import createFlash from '~/flash'; import { sprintf, s__ } from '~/locale'; import { METRICS_POPOVER_CONTENT } from '../constants'; @@ -28,6 +28,23 @@ const fetchMetricsData = (reqs = [], path, params) => { ); }; +const extractMetricsGroupData = (keyList = [], data = []) => { + if (!keyList.length || !data.length) return []; + const kv = keyBy(data, 'identifier'); + return keyList.map((id) => kv[id] || null).filter((obj) => Boolean(obj)); +}; + +const groupRawMetrics = (groups = [], rawData = []) => { + return groups.map((curr) => { + const { keys, ...rest } = curr; + return { + data: extractMetricsGroupData(keys, rawData), + keys, + ...rest, + }; + }); +}; + export default { name: 'ValueStreamMetrics', components: { @@ -52,13 +69,24 @@ export default { required: false, default: null, }, + groupBy: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { metrics: [], + groupedMetrics: [], isLoading: false, }; }, + computed: { + hasGroupedMetrics() { + return Boolean(this.groupBy.length); + }, + }, watch: { requestParams(newVal, oldVal) { if (!isEqual(newVal, oldVal)) { @@ -76,6 +104,11 @@ export default { return fetchMetricsData(this.requests, this.requestPath, this.requestParams) .then((data) => { this.metrics = this.filterFn ? this.filterFn(data) : data; + + if (this.hasGroupedMetrics) { + this.groupedMetrics = groupRawMetrics(this.groupBy, this.metrics); + } + this.isLoading = false; }) .catch(() => { @@ -86,14 +119,35 @@ export default { }; </script> <template> - <div class="gl-display-flex gl-flex-wrap" data-testid="vsa-metrics"> + <div class="gl-display-flex gl-mt-6" data-testid="vsa-metrics"> <gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6" /> - <metric-tile - v-for="metric in metrics" - v-show="!isLoading" - :key="metric.identifier" - :metric="metric" - class="gl-my-6 gl-pr-9" - /> + <template v-else> + <div v-if="hasGroupedMetrics" class="gl-flex-direction-column"> + <div + v-for="group in groupedMetrics" + :key="group.key" + class="gl-mb-7" + data-testid="vsa-metrics-group" + > + <h4 class="gl-my-0">{{ group.title }}</h4> + <div class="gl-display-flex gl-flex-wrap"> + <metric-tile + v-for="metric in group.data" + :key="metric.identifier" + :metric="metric" + class="gl-mt-5 gl-pr-10" + /> + </div> + </div> + </div> + <div v-else class="gl-display-flex gl-flex-wrap gl-mb-7"> + <metric-tile + v-for="metric in metrics" + :key="metric.identifier" + :metric="metric" + class="gl-mt-5 gl-pr-10" + /> + </div> + </template> </div> </template> diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js index 2ac144ceb5e..38d05552783 100644 --- a/app/assets/javascripts/analytics/shared/constants.js +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -55,4 +55,40 @@ export const METRICS_POPOVER_CONTENT = { commits: { description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'), }, + 'time-to-restore-service': { + description: s__( + 'ValueStreamAnalytics|Median time an incident was open on a production environment in the given time period.', + ), + }, + time_to_restore_service: { + description: s__( + 'ValueStreamAnalytics|Median time an incident was open on a production environment in the given time period.', + ), + }, + 'change-failure-rate': { + description: s__( + 'ValueStreamAnalytics|Percentage of deployments that cause an incident in production.', + ), + }, + change_failure_rate: { + description: s__( + 'ValueStreamAnalytics|Percentage of deployments that cause an incident in production.', + ), + }, }; + +const KEY_METRICS_TITLE = s__('ValueStreamAnalytics|Key metrics'); +const KEY_METRICS_KEYS = ['lead_time', 'cycle_time', 'issues', 'commits', 'deploys']; + +const DORA_METRICS_TITLE = s__('ValueStreamAnalytics|DORA metrics'); +const DORA_METRICS_KEYS = [ + 'deployment_frequency', + 'lead_time_for_changes', + 'time_to_restore_service', + 'change_failure_rate', +]; + +export const VSA_METRICS_GROUPS = [ + { key: 'key_metrics', title: KEY_METRICS_TITLE, keys: KEY_METRICS_KEYS }, + { key: 'dora_metrics', title: DORA_METRICS_TITLE, keys: DORA_METRICS_KEYS }, +]; diff --git a/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue index 09dfcddcb73..dfe94aeb884 100644 --- a/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue @@ -122,7 +122,7 @@ export default { <div> <h3>{{ $options.i18n.yAxisTitle }}</h3> <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3"> - {{ this.$options.i18n.loadUserChartError }} + {{ $options.i18n.loadUserChartError }} </gl-alert> <chart-skeleton-loader v-else-if="isLoading" /> <gl-alert v-else-if="!chartUserData.length" variant="info" :dismissible="false" class="gl-mt-3"> @@ -132,12 +132,12 @@ export default { v-else :option="options" :include-legend-avg-max="true" - :data="[ + :data="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ { name: $options.i18n.yAxisTitle, data: chartUserData, }, - ]" + ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" /> </div> </template> diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 64812e52849..8d46ea76be1 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -93,6 +93,7 @@ const Api = { groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings', notificationSettingsPath: '/api/:version/notification_settings', deployKeysPath: '/api/:version/deploy_keys', + secureFilePath: '/api/:version/projects/:project_id/secure_files/:secure_file_id', secureFilesPath: '/api/:version/projects/:project_id/secure_files', dependencyProxyPath: '/api/:version/groups/:id/dependency_proxy/cache', @@ -857,6 +858,14 @@ const Api = { }); }, + tag(id, tagName) { + const url = Api.buildUrl(this.tagPath) + .replace(':id', encodeURIComponent(id)) + .replace(':tag_name', encodeURIComponent(tagName)); + + return axios.get(url); + }, + freezePeriods(id) { const url = Api.buildUrl(this.freezePeriodsPath).replace(':id', encodeURIComponent(id)); @@ -970,6 +979,22 @@ const Api = { return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...options } }); }, + uploadProjectSecureFile(projectId, fileData) { + const url = Api.buildUrl(this.secureFilesPath).replace(':project_id', projectId); + + const headers = { 'Content-Type': 'multipart/form-data' }; + + return axios.post(url, fileData, { headers }); + }, + + deleteProjectSecureFile(projectId, secureFileId) { + const url = Api.buildUrl(this.secureFilePath) + .replace(':project_id', projectId) + .replace(':secure_file_id', secureFileId); + + return axios.delete(url); + }, + async updateNotificationSettings(projectId, groupId, data = {}) { let url = Api.buildUrl(this.notificationSettingsPath); diff --git a/app/assets/javascripts/api/integrations_api.js b/app/assets/javascripts/api/integrations_api.js new file mode 100644 index 00000000000..692aae21a4f --- /dev/null +++ b/app/assets/javascripts/api/integrations_api.js @@ -0,0 +1,21 @@ +import axios from '../lib/utils/axios_utils'; +import { buildApiUrl } from './api_utils'; + +const JIRA_CONNECT_SUBSCRIPTIONS_PATH = '/api/:version/integrations/jira_connect/subscriptions'; + +export function addJiraConnectSubscription(namespacePath, { jwt, accessToken }) { + const url = buildApiUrl(JIRA_CONNECT_SUBSCRIPTIONS_PATH); + + return axios.post( + url, + { + jwt, + namespace_path: namespacePath, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, // eslint-disable-line @gitlab/require-i18n-strings + }, + }, + ); +} diff --git a/app/assets/javascripts/api/tags_api.js b/app/assets/javascripts/api/tags_api.js new file mode 100644 index 00000000000..d4ee247ade6 --- /dev/null +++ b/app/assets/javascripts/api/tags_api.js @@ -0,0 +1,12 @@ +import axios from '../lib/utils/axios_utils'; +import { buildApiUrl } from './api_utils'; + +const TAG_PATH = '/api/:version/projects/:id/repository/tags/:tag_name'; + +export function getTag(id, tagName) { + const url = buildApiUrl(TAG_PATH) + .replace(':id', encodeURIComponent(id)) + .replace(':tag_name', encodeURIComponent(tagName)); + + return axios.get(url); +} diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index 09995fad628..c362253f52e 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -10,6 +10,9 @@ const USER_PATH = '/api/:version/users/:id'; const USER_STATUS_PATH = '/api/:version/users/:id/status'; const USER_PROJECTS_PATH = '/api/:version/users/:id/projects'; const USER_POST_STATUS_PATH = '/api/:version/user/status'; +const USER_FOLLOW_PATH = '/api/:version/users/:id/follow'; +const USER_UNFOLLOW_PATH = '/api/:version/users/:id/unfollow'; +const CURRENT_USER_PATH = '/api/:version/user'; export function getUsers(query, options) { const url = buildApiUrl(USERS_PATH); @@ -69,3 +72,18 @@ export function updateUserStatus({ emoji, message, availability, clearStatusAfte clear_status_after: clearStatusAfter, }); } + +export function followUser(userId) { + const url = buildApiUrl(USER_FOLLOW_PATH).replace(':id', encodeURIComponent(userId)); + return axios.post(url); +} + +export function unfollowUser(userId) { + const url = buildApiUrl(USER_UNFOLLOW_PATH).replace(':id', encodeURIComponent(userId)); + return axios.post(url); +} + +export function getCurrentUser(options) { + const url = buildApiUrl(CURRENT_USER_PATH); + return axios.get(url, { ...options }); +} diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js index c3c28aeafc0..07fd6dae76a 100644 --- a/app/assets/javascripts/behaviors/copy_to_clipboard.js +++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js @@ -43,7 +43,7 @@ function genericSuccess(e) { } /** - * Safari > 10 doesn't support `execCommand`, so instead we inform the user to copy manually. + * Safari < 10 doesn't support `execCommand`, so instead we inform the user to copy manually. * See http://clipboardjs.com/#browser-support */ function genericError(e) { diff --git a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js index 6922ec9c5a5..3b9f6011c6d 100644 --- a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js @@ -53,9 +53,6 @@ function fixElementSource(el) { // Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly. const source = el.textContent?.replace(/<br\s*\/>/g, '<br>'); - // Remove any extra spans added by the backend syntax highlighting. - Object.assign(el, { textContent: source }); - return { source }; } @@ -78,17 +75,13 @@ function renderMermaidEl(el, source) { width: '100%', }); - // Add the original source into the DOM - // to allow Copy-as-GFM to access it. - const sourceEl = document.createElement('text'); - sourceEl.textContent = source; - sourceEl.classList.add('gl-display-none'); - const wrapper = document.createElement('div'); wrapper.appendChild(iframeEl); - wrapper.appendChild(sourceEl); - el.closest('pre').replaceWith(wrapper); + // Hide the markdown but keep it "visible enough" to allow Copy-as-GFM + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/83202 + el.closest('pre').classList.add('gl-sr-only'); + el.closest('pre').parentNode.appendChild(wrapper); // Event Listeners iframeEl.addEventListener('load', () => { diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 6124befd3b6..82229b5aa8f 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -156,7 +156,7 @@ export default class ShortcutsIssuable extends Shortcuts { static copyBranchName() { // There are two buttons - one that is shown when the sidebar // is expanded, and one that is shown when it's collapsed. - const allCopyBtns = Array.from(document.querySelectorAll('.js-sidebar-source-branch button')); + const allCopyBtns = Array.from(document.querySelectorAll('.js-source-branch-copy')); // Select whichever button is currently visible so that // the "Copied" tooltip is shown when a click is simulated. diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue index 9832ebbea5c..f032e2e7fb8 100644 --- a/app/assets/javascripts/blob/components/blob_content.vue +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -66,7 +66,7 @@ export default { </script> <template> <div class="blob-viewer" :data-type="activeViewer.type" :data-loaded="!loading"> - <gl-loading-icon v-if="loading" size="md" color="dark" class="my-4 mx-auto" /> + <gl-loading-icon v-if="loading" size="lg" color="dark" class="my-4 mx-auto" /> <template v-else> <blob-content-error diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index 8a4fe1a9025..f78d921fa90 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -92,7 +92,7 @@ export default { </blob-filepath> </div> - <div class="gl-sm-display-flex file-actions"> + <div class="gl-display-flex gl-flex-wrap file-actions"> <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" :doc-icon="blobSwitcherDocIcon" /> <slot name="actions"></slot> diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js index e02217d0deb..4f970d657c2 100644 --- a/app/assets/javascripts/blob/file_template_selector.js +++ b/app/assets/javascripts/blob/file_template_selector.js @@ -1,10 +1,14 @@ import $ from 'jquery'; +import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; export default class FileTemplateSelector { constructor(mediator) { this.mediator = mediator; this.$dropdown = null; this.$wrapper = null; + + this.dropdown = null; + this.wrapper = null; } init() { @@ -12,18 +16,21 @@ export default class FileTemplateSelector { this.$dropdown = $(cfg.dropdown); this.$wrapper = $(cfg.wrapper); - this.$dropdownIcon = this.$wrapper.find('.dropdown-menu-toggle-icon'); - this.$loadingIcon = $( - '<div class="gl-spinner gl-spinner-orange gl-spinner-sm gl-absolute gl-top-3 gl-right-3 gl-display-none"></div>', - ).insertAfter(this.$dropdownIcon); - this.$dropdownToggleText = this.$wrapper.find('.dropdown-toggle-text'); + + this.dropdown = document.querySelector(cfg.dropdown); + this.wrapper = document.querySelector(cfg.wrapper); + + this.dropdownIcon = this.wrapper.querySelector('.dropdown-menu-toggle-icon'); + this.loadingIcon = loadingIconForLegacyJS({ classes: ['gl-display-none'] }); + this.dropdown.appendChild(this.loadingIcon); + this.dropdownToggleText = this.wrapper.querySelector('.dropdown-toggle-text'); this.initDropdown(); this.selectInitialTemplate(); } selectInitialTemplate() { - const template = this.$dropdown.data('selected'); + const template = this.dropdown.dataset.selected; if (!template) { return; @@ -33,11 +40,11 @@ export default class FileTemplateSelector { } show() { - if (this.$dropdown === null) { + if (this.dropdown === null) { this.init(); } - this.$wrapper.removeClass('hidden'); + this.wrapper.classList.remove('hidden'); /** * We set the focus on the dropdown that was just shown. This is done so that, after selecting @@ -49,36 +56,36 @@ export default class FileTemplateSelector { * closed anymore. */ setTimeout(() => { - this.$dropdown.focus(); + this.dropdown.focus(); }, 0); } hide() { - if (this.$dropdown !== null) { - this.$wrapper.addClass('hidden'); + if (this.dropdown !== null) { + this.wrapper.classList.add('hidden'); } } isHidden() { - return !this.$wrapper || this.$wrapper.hasClass('hidden'); + return !this.wrapper || this.wrapper.classList.contains('hidden'); } getToggleText() { - return this.$dropdownToggleText.text(); + return this.dropdownToggleText.textContent; } setToggleText(text) { - this.$dropdownToggleText.text(text); + this.dropdownToggleText.textContent = text; } renderLoading() { - this.$loadingIcon.removeClass('gl-display-none'); - this.$dropdownIcon.addClass('gl-display-none'); + this.loadingIcon.classList.remove('gl-display-none'); + this.dropdownIcon.classList.add('gl-display-none'); } renderLoaded() { - this.$loadingIcon.addClass('gl-display-none'); - this.$dropdownIcon.removeClass('gl-display-none'); + this.loadingIcon.classList.add('gl-display-none'); + this.dropdownIcon.classList.remove('gl-display-none'); } reportSelection(options) { diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js index a04da98ff77..4c497db9842 100644 --- a/app/assets/javascripts/blob/openapi/index.js +++ b/app/assets/javascripts/blob/openapi/index.js @@ -11,6 +11,7 @@ export default () => { url: el.dataset.endpoint, dom_id: '#js-openapi-viewer', deepLinking: true, + displayOperationId: true, }); }) .catch((error) => { diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue index b4ca29114cb..f3c542c467a 100644 --- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue +++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue @@ -132,7 +132,7 @@ export default { <gl-button ref="goToPipelines" :href="goToPipelinesPath" - variant="success" + variant="confirm" :data-track-property="humanAccess" :data-track-value="$options.goToTrackValuePipelines" :data-track-action="$options.trackEvent" diff --git a/app/assets/javascripts/blob/sketch/index.js b/app/assets/javascripts/blob/sketch/index.js index d257810da65..a92161bbc1b 100644 --- a/app/assets/javascripts/blob/sketch/index.js +++ b/app/assets/javascripts/blob/sketch/index.js @@ -27,7 +27,7 @@ export default class SketchLoader { } getZipFile() { - return new JSZip.external.Promise((resolve, reject) => { + return new Promise((resolve, reject) => { JSZipUtils.getBinaryContent(this.container.dataset.endpoint, (err, data) => { if (err) { reject(err); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index ee2f6cfb46c..2ee2e199358 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; +import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext'; import SourceEditor from '~/editor/source_editor'; -import { getBlobLanguage } from '~/editor/utils'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; @@ -36,7 +36,7 @@ export default class EditBlob { import('~/editor/extensions/source_editor_markdown_ext'), import('~/editor/extensions/source_editor_markdown_livepreview_ext'), ]); - this.editor.use([ + this.markdownExtensions = this.editor.use([ { definition: MarkdownExtension }, { definition: MarkdownLivePreview, @@ -48,7 +48,6 @@ export default class EditBlob { message: `${BLOB_EDITOR_ERROR}: ${e}`, }); } - this.hasMarkdownExtension = true; addEditorMarkdownListeners(this.editor); } @@ -58,8 +57,6 @@ export default class EditBlob { const fileContentEl = document.getElementById('file-content'); const form = document.querySelector('.js-edit-blob-form'); - this.hasMarkdownExtension = false; - const rootEditor = new SourceEditor(); this.editor = rootEditor.createInstance({ @@ -67,21 +64,29 @@ export default class EditBlob { blobPath: fileNameEl.value, blobContent: editorEl.innerText, }); - this.editor.use([{ definition: SourceEditorExtension }, { definition: FileTemplateExtension }]); + this.editor.use([ + { definition: SourceEditorExtension }, + { definition: FileTemplateExtension }, + { definition: ToolbarExtension }, + ]); fileNameEl.addEventListener('change', () => { this.editor.updateModelLanguage(fileNameEl.value); - const newLang = getBlobLanguage(fileNameEl.value); - if (newLang === 'markdown') { - if (!this.hasMarkdownExtension) { - this.fetchMarkdownExtension(); - } - } }); form.addEventListener('submit', () => { fileContentEl.value = insertFinalNewline(this.editor.getValue()); }); + + // onDidChangeModelLanguage is part of the native Monaco API + // https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.IStandaloneCodeEditor.html#onDidChangeModelLanguage + this.editor.onDidChangeModelLanguage(({ newLanguage = '', oldLanguage = '' }) => { + if (newLanguage === 'markdown') { + this.fetchMarkdownExtension(); + } else if (oldLanguage === 'markdown') { + this.editor.unuse(this.markdownExtensions); + } + }); } initFileSelectors() { diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue index 70ba90bb1d4..10c7a3db2d3 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue @@ -80,17 +80,14 @@ export default { <template> <div - class="board-add-new-list board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal gl-flex-shrink-0" + class="board-add-new-list board gl-display-inline-block gl-h-full gl-vertical-align-top gl-white-space-normal gl-flex-shrink-0 gl-rounded-base gl-px-3" data-testid="board-add-new-column" data-qa-selector="board_add_new_list" > <div - class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-white" + class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" > - <h3 - class="gl-font-size-h2 gl-px-5 gl-py-4 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" - data-testid="board-add-column-form-title" - > + <h3 class="gl-font-size-h2 gl-px-5 gl-py-5 gl-m-0" data-testid="board-add-column-form-title"> {{ $options.i18n.newList }} </h3> @@ -98,7 +95,7 @@ export default { class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-y-auto gl-align-items-flex-start" > <div class="gl-px-5"> - <h3 class="gl-font-lg gl-mt-5 gl-mb-2"> + <h3 class="gl-font-lg gl-mt-3 gl-mb-2"> {{ $options.i18n.scope }} </h3> <p class="gl-mb-3">{{ $options.i18n.scopeDescription }}</p> @@ -147,23 +144,18 @@ export default { </gl-dropdown> </gl-form-group> </div> - <div - class="gl-display-flex gl-p-3 gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" - > - <gl-button - data-testid="cancelAddNewColumn" - class="gl-ml-auto gl-mr-3" - @click="setAddColumnFormVisibility(false)" - >{{ $options.i18n.cancel }}</gl-button - > + <div class="gl-display-flex gl-mb-4"> <gl-button data-testid="addNewColumnButton" :disabled="!selectedId" variant="confirm" - class="gl-mr-4" + class="gl-mr-3 gl-ml-4" @click="$emit('add-list')" >{{ $options.i18n.add }}</gl-button > + <gl-button data-testid="cancelAddNewColumn" @click="setAddColumnFormVisibility(false)">{{ + $options.i18n.cancel + }}</gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 814ff16efec..98ce1ac7f97 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -11,10 +11,12 @@ import { sortBy } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; import { isScopedLabel } from '~/lib/utils/common_utils'; +import { updateHistory } from '~/lib/utils/url_utility'; import { sprintf, __, n__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import { ListType } from '../constants'; +import eventHub from '../eventhub'; import BoardBlockedIcon from './board_blocked_icon.vue'; import IssueDueDate from './issue_due_date.vue'; import IssueTimeEstimate from './issue_time_estimate.vue'; @@ -174,10 +176,19 @@ export default { ) ); }, - labelTarget(label) { + filterByLabel(label) { + if (!this.updateFilters) return; + const filterPath = window.location.search ? `${window.location.search}&` : '?'; - const value = encodeURIComponent(label.title); - return `${filterPath}label_name[]=${value}`; + const filter = `label_name[]=${encodeURIComponent(label.title)}`; + + if (!filterPath.includes(filter)) { + updateHistory({ + url: `${filterPath}${filter}`, + }); + this.performSearch(); + eventHub.$emit('updateTokens'); + } }, showScopedLabel(label) { return this.scopedLabelsAvailable && isScopedLabel(label); @@ -232,7 +243,7 @@ export default { :description="label.description" size="sm" :scoped="showScopedLabel(label)" - :target="labelTarget(label)" + @click="filterByLabel(label)" /> </template> </div> @@ -242,7 +253,7 @@ export default { <div class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden" > - <gl-loading-icon v-if="item.isLoading" size="md" class="mt-3" /> + <gl-loading-icon v-if="item.isLoading" size="lg" class="mt-3" /> <span v-if="item.referencePath" class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3" diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 1d6a71aca47..8868b9b2f3e 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -75,7 +75,7 @@ export default { v-if="!isSwimlanesOn" ref="list" v-bind="draggableOptions" - class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap" + class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap" @end="moveList" > <board-column @@ -85,10 +85,11 @@ export default { :list="list" :data-draggable-item-type="$options.draggableItemTypes.list" :disabled="disabled" + :class="{ 'gl-xs-display-none!': addColumnFormVisible }" /> <transition name="slide" @after-enter="afterFormEnters"> - <board-add-new-column v-if="addColumnFormVisible" /> + <board-add-new-column v-if="addColumnFormVisible" class="gl-xs-w-full!" /> </transition> </component> diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index aeb2cee590d..fa0c798ca9d 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -2,7 +2,8 @@ import { pickBy, isEmpty, mapValues } from 'lodash'; import { mapActions } from 'vuex'; import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; -import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { FILTERED_SEARCH_TERM, @@ -10,6 +11,7 @@ import { } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { AssigneeFilterType } from '~/boards/constants'; +import eventHub from '../eventhub'; export default { i18n: { @@ -33,6 +35,7 @@ export default { data() { return { filterParams: this.initialFilterParams, + filteredSearchKey: 0, }; }, computed: { @@ -306,12 +309,21 @@ export default { }, }, created() { + eventHub.$on('updateTokens', this.updateTokens); if (!isEmpty(this.eeFilters)) { this.filterParams = this.eeFilters; } }, + beforeDestroy() { + eventHub.$off('updateTokens', this.updateTokens); + }, methods: { ...mapActions(['performSearch']), + updateTokens() { + const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); + this.filterParams = convertObjectPropsToCamelCase(rawFilterParams, {}); + this.filteredSearchKey += 1; + }, handleFilter(filters) { this.filterParams = this.getFilterParams(filters); @@ -399,6 +411,7 @@ export default { <template> <filtered-search + :key="filteredSearchKey" class="gl-w-full" namespace="" :tokens="tokens" diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index a874c9e070a..9d972860d06 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -17,6 +17,7 @@ const boardDefaults = { labels: [], milestone: {}, iterationCadence: {}, + iterationCadenceId: null, iteration: {}, assignee: {}, weight: null, diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 47f25f34d0c..66388f4eb43 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -287,7 +287,7 @@ export default { :data-board-type="list.listType" :class="{ 'bg-danger-100': boardItemsSizeExceedsMax }" draggable=".board-card" - class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2" + class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-3 gl-pt-0" data-testid="tree-root-wrapper" @start="handleDragOnStart" @end="handleDragOnEnd" diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 9f70c84931f..a4298eb2544 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -126,7 +126,7 @@ export default { return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse; }, chevronIcon() { - return this.list.collapsed ? 'chevron-down' : 'chevron-right'; + return this.list.collapsed ? 'chevron-right' : 'chevron-down'; }, isNewIssueShown() { return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard; @@ -248,7 +248,6 @@ export default { <template> <header :class="{ - 'has-border': list.label && list.label.color, 'gl-h-full': list.collapsed, 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader, }" @@ -279,28 +278,6 @@ export default { @click="toggleExpanded" /> <!-- EE start --> - <span - v-if="showMilestoneListDetails" - aria-hidden="true" - class="milestone-icon" - :class="{ - 'gl-mt-3 gl-rotate-90': list.collapsed, - 'gl-mr-2': !list.collapsed, - }" - > - <gl-icon name="timer" /> - </span> - - <span - v-if="showIterationListDetails" - aria-hidden="true" - :class="{ - 'gl-mt-3 gl-rotate-90': list.collapsed, - 'gl-mr-2': !list.collapsed, - }" - > - <gl-icon name="iteration" /> - </span> <a v-if="showAssigneeListDetails" @@ -399,7 +376,7 @@ export default { <span class="gl-display-inline-flex"> <gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" /> <span ref="itemCount" class="gl-display-inline-flex gl-align-items-center"> - <gl-icon class="gl-mr-2" :name="countIcon" /> + <gl-icon class="gl-mr-2" :name="countIcon" :size="16" /> <item-count v-if="!isLoading" :items-size="isEpicBoard ? list.epicsCount : boardList.issuesCount" diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 24071c6f0b4..c559e4cdbd3 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -135,14 +135,14 @@ export default { :modal-id="$options.modalId" :title="$options.i18n.modalAction" size="sm" - :action-primary="{ + :action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { text: $options.i18n.modalAction, attributes: [{ variant: 'danger' }], - }" - :action-secondary="{ + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :action-secondary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { text: $options.i18n.modalCancel, attributes: [{ variant: 'default' }], - }" + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @primary="handleModalPrimary" > <p>{{ $options.i18n.modalCopy }}</p> diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue index f90ac1e9079..54a6e3000a4 100644 --- a/app/assets/javascripts/boards/components/board_top_bar.vue +++ b/app/assets/javascripts/boards/components/board_top_bar.vue @@ -33,7 +33,7 @@ export default { class="issues-details-filters filtered-search-block gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row row-content-block second-block" > <div - class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-flex-grow-1 gl-lg-mb-0! mb-md-2 mb-sm-0 gl-w-full" + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-flex-grow-1 gl-lg-mb-0 gl-mb-3 gl-w-full" > <boards-selector /> <new-board-button /> @@ -41,7 +41,7 @@ export default { <issue-board-filtered-search v-else /> </div> <div - class="filter-dropdown-container gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-align-items-flex-start" + class="filter-dropdown-container gl-md-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-align-items-flex-start" > <toggle-labels /> <toggle-epics-swimlanes v-if="swimlanesFeatureAvailable && isSignedIn" /> diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue index 4746f598ab7..7002fd44294 100644 --- a/app/assets/javascripts/boards/components/config_toggle.vue +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { mapGetters } from 'vuex'; import { formType } from '~/boards/constants'; import eventHub from '~/boards/eventhub'; import { s__, __ } from '~/locale'; @@ -14,8 +15,9 @@ export default { GlModalDirective, }, mixins: [Tracking.mixin()], - inject: ['canAdminList', 'hasScope'], + inject: ['canAdminList'], computed: { + ...mapGetters(['hasScope']), buttonText() { return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope'); }, diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 1412411c275..247910301e7 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -91,6 +91,9 @@ export default { loadMoreProjects() { this.fetchGroupProjects({ search: this.searchTerm, fetchNext: true }); }, + setFocus() { + this.$refs.search.focusInput(); + }, }, }; </script> @@ -107,8 +110,10 @@ export default { block menu-class="gl-w-full!" :loading="initialLoading" + @shown="setFocus" > <gl-search-box-by-type + ref="search" v-model.trim="searchTerm" debounce="250" :placeholder="$options.i18n.searchPlaceholder" @@ -135,7 +140,7 @@ export default { <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> </gl-dropdown-text> <gl-intersection-observer v-if="hasNextPage" @appear="loadMoreProjects"> - <gl-loading-icon v-if="groupProjectsFlags.isLoadingMore" size="md" /> + <gl-loading-icon v-if="groupProjectsFlags.isLoadingMore" size="lg" /> </gl-intersection-observer> </gl-dropdown> </div> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue index 9d19fe57e7a..53e574e9942 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -130,7 +130,7 @@ export default { @off-click="handleOffClick" > <template #title> - <span class="gl-font-weight-bold" data-testid="item-title">{{ item.title }}</span> + <span data-testid="item-title">{{ item.title }}</span> </template> <template #collapsed> <span class="gl-text-gray-800">{{ item.referencePath }}</span> diff --git a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql index 81cc7b4d246..0e1d11727cf 100644 --- a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql @@ -2,8 +2,6 @@ mutation createBoardList($boardId: BoardID!, $backlog: Boolean, $labelId: LabelID) { boardListCreate(input: { boardId: $boardId, backlog: $backlog, labelId: $labelId }) { - # We have ID in a deeply nested fragment - # eslint-disable-next-line @graphql-eslint/require-id-when-available list { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/graphql/board_list_destroy.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_destroy.mutation.graphql index ef3fd36e980..5cb1a74d5c7 100644 --- a/app/assets/javascripts/boards/graphql/board_list_destroy.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_destroy.mutation.graphql @@ -1,4 +1,4 @@ -mutation DestroyBoardList($listId: ID!) { +mutation DestroyBoardList($listId: ListID!) { destroyBoardList(input: { listId: $listId }) { errors } diff --git a/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql index 7ea0e2f915a..13327028065 100644 --- a/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql @@ -1,9 +1,7 @@ #import "./board_list.fragment.graphql" -mutation UpdateBoardList($listId: ID!, $position: Int, $collapsed: Boolean) { +mutation UpdateBoardList($listId: ListID!, $position: Int, $collapsed: Boolean) { updateBoardList(input: { listId: $listId, position: $position, collapsed: $collapsed }) { - # We have ID in a deeply nested fragment - # eslint-disable-next-line @graphql-eslint/require-id-when-available list { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/graphql/board_lists.query.graphql b/app/assets/javascripts/boards/graphql/board_lists.query.graphql index e6e98864aad..06e8c8783de 100644 --- a/app/assets/javascripts/boards/graphql/board_lists.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_lists.query.graphql @@ -2,7 +2,7 @@ query BoardLists( $fullPath: ID! - $boardId: ID! + $boardId: BoardID! $filters: BoardIssueInput $isGroup: Boolean = false $isProject: Boolean = false @@ -13,8 +13,6 @@ query BoardLists( id hideBacklogList lists(issueFilters: $filters) { - # We have ID in a deeply nested fragment - # eslint-disable-next-line @graphql-eslint/require-id-when-available nodes { ...BoardListFragment } @@ -27,8 +25,6 @@ query BoardLists( id hideBacklogList lists(issueFilters: $filters) { - # We have ID in a deeply nested fragment - # eslint-disable-next-line @graphql-eslint/require-id-when-available nodes { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql b/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql index bae3220dfad..f48383624c9 100644 --- a/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql @@ -1,4 +1,4 @@ -query BoardList($id: ID!, $filters: BoardIssueInput) { +query BoardList($id: ListID!, $filters: BoardIssueInput) { boardList(id: $id, issueFilters: $filters) { id issuesCount diff --git a/app/assets/javascripts/boards/graphql/group_board.query.graphql b/app/assets/javascripts/boards/graphql/group_board.query.graphql index 8d87b83da96..526e3193efe 100644 --- a/app/assets/javascripts/boards/graphql/group_board.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_board.query.graphql @@ -1,6 +1,6 @@ #import "ee_else_ce/boards/graphql/board_scope.fragment.graphql" -query GroupBoard($fullPath: ID!, $boardId: ID!) { +query GroupBoard($fullPath: ID!, $boardId: BoardID!) { workspace: group(fullPath: $fullPath) { id board(id: $boardId) { diff --git a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql index 1658cf09085..89670760450 100644 --- a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql @@ -3,7 +3,7 @@ mutation issueMoveList( $projectPath: ID! $iid: String! - $boardId: ID! + $boardId: BoardID! $fromListId: ID $toListId: ID $moveBeforeId: ID diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql index 994ea894be3..bf5329c4a8d 100644 --- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql @@ -2,8 +2,8 @@ query BoardListsEE( $fullPath: ID! - $boardId: ID! - $id: ID + $boardId: BoardID! + $id: ListID $filters: BoardIssueInput $isGroup: Boolean = false $isProject: Boolean = false diff --git a/app/assets/javascripts/boards/graphql/project_board.query.graphql b/app/assets/javascripts/boards/graphql/project_board.query.graphql index 8246d615a6a..2a9142696d2 100644 --- a/app/assets/javascripts/boards/graphql/project_board.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_board.query.graphql @@ -1,6 +1,6 @@ #import "ee_else_ce/boards/graphql/board_scope.fragment.graphql" -query ProjectBoard($fullPath: ID!, $boardId: ID!) { +query ProjectBoard($fullPath: ID!, $boardId: BoardID!) { workspace: project(fullPath: $fullPath) { id board(id: $boardId) { diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 77c5994b5a1..8af7da1e0aa 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -69,7 +69,6 @@ function mountBoardApp(el) { timeTrackingLimitToHours: parseBoolean(el.dataset.timeTrackingLimitToHours), issuableType: issuableTypes.issue, emailsDisabled: parseBoolean(el.dataset.emailsDisabled), - hasScope: parseBoolean(el.dataset.hasScope), hasMissingBoards: parseBoolean(el.dataset.hasMissingBoards), weights: el.dataset.weights ? JSON.parse(el.dataset.weights) : [], // Permissions diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 82307da2572..a84b678a5d9 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -77,6 +77,7 @@ export default { milestoneTitle: board.milestone?.title || null, iterationId: board.iteration?.id || null, iterationTitle: board.iteration?.title || null, + iterationCadenceId: board.iterationCadence?.id || null, assigneeId: board.assignee?.id || null, assigneeUsername: board.assignee?.username || null, labels: board.labels?.nodes || [], @@ -134,7 +135,7 @@ export default { variables, }) .then(({ data }) => { - const { lists, hideBacklogList } = data[boardType]?.board; + const { lists, hideBacklogList } = data[boardType].board; commit(types.RECEIVE_BOARD_LISTS_SUCCESS, formatBoardLists(lists)); // Backlog list needs to be created if it doesn't exist and it's not hidden if (!lists.nodes.find((l) => l.listType === ListType.backlog) && !hideBacklogList) { @@ -429,7 +430,7 @@ export default { variables, }) .then(({ data }) => { - const { lists } = data[boardType]?.board; + const { lists } = data[boardType].board; const listItems = formatListIssues(lists); const listPageInfo = formatListsPageInfo(lists); commit(types.RECEIVE_ITEMS_FOR_LIST_SUCCESS, { listItems, listPageInfo, listId }); diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index cb31eb4b008..e1891a4d954 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -51,4 +51,18 @@ export default { isEpicBoard: () => { return false; }, + + hasScope: (state) => { + const { boardConfig } = state; + if (boardConfig.labels?.length > 0) { + return true; + } + let hasScope = false; + ['assigneeId', 'iterationCadenceId', 'iterationId', 'milestoneId', 'weight'].forEach((attr) => { + if (boardConfig[attr] !== null && boardConfig[attr] !== undefined) { + hasScope = true; + } + }); + return hasScope; + }, }; diff --git a/app/assets/javascripts/branches/components/delete_branch_modal.vue b/app/assets/javascripts/branches/components/delete_branch_modal.vue index 14c2badeb3f..383fa5f7512 100644 --- a/app/assets/javascripts/branches/components/delete_branch_modal.vue +++ b/app/assets/javascripts/branches/components/delete_branch_modal.vue @@ -32,17 +32,10 @@ export default { return sprintf(modalTitle, { branchName: this.branchName }); }, - message() { - const modalMessage = this.isProtectedBranch + modalMessage() { + return this.isProtectedBranch ? this.$options.i18n.modalMessageProtectedBranch : this.$options.i18n.modalMessage; - - return sprintf(modalMessage, { branchName: this.branchName }); - }, - unmergedWarning() { - return sprintf(this.$options.i18n.unmergedWarning, { - defaultBranchName: this.defaultBranchName, - }); }, undoneWarning() { return sprintf(this.$options.i18n.undoneWarning, { @@ -92,17 +85,15 @@ export default { i18n: { modalTitle: s__('Branches|Delete branch. Are you ABSOLUTELY SURE?'), modalTitleProtectedBranch: s__('Branches|Delete protected branch. Are you ABSOLUTELY SURE?'), - modalMessage: s__( - "Branches|You're about to permanently delete the branch %{strongStart}%{branchName}.%{strongEnd}", - ), + modalMessage: s__("Branches|You're about to permanently delete the branch %{branchName}."), modalMessageProtectedBranch: s__( - "Branches|You're about to permanently delete the protected branch %{strongStart}%{branchName}.%{strongEnd}", + "Branches|You're about to permanently delete the protected branch %{branchName}.", ), unmergedWarning: s__( - 'Branches|This branch hasn’t been merged into %{defaultBranchName}. To avoid data loss, consider merging this branch before deleting it.', + "Branches|This branch hasn't been merged into %{defaultBranchName}. To avoid data loss, consider merging this branch before deleting it.", ), undoneWarning: s__( - 'Branches|Once you confirm and press %{strongStart}%{buttonText},%{strongEnd} it cannot be undone or recovered.', + 'Branches|After you confirm and select %{strongStart}%{buttonText},%{strongEnd} you cannot recover this branch.', ), cancelButtonText: s__('Branches|Cancel, keep branch'), confirmationText: s__( @@ -119,13 +110,19 @@ export default { <gl-modal ref="modal" size="sm" :modal-id="modalId" :title="title"> <gl-alert class="gl-mb-5" variant="danger" :dismissible="false"> <div data-testid="modal-message"> - <gl-sprintf :message="message"> - <template #strong="{ content }"> - <strong> {{ content }} </strong> + <gl-sprintf :message="modalMessage"> + <template #branchName> + <strong> + <code class="gl-white-space-pre-wrap">{{ branchName }}</code> + </strong> </template> </gl-sprintf> <p v-if="!merged" class="gl-mb-0 gl-mt-4"> - {{ unmergedWarning }} + <gl-sprintf :message="$options.i18n.unmergedWarning"> + <template #defaultBranchName> + <code class="gl-white-space-pre-wrap">{{ defaultBranchName }}</code> + </template> + </gl-sprintf> </p> </div> </gl-alert> @@ -145,7 +142,7 @@ export default { {{ content }} </template> </gl-sprintf> - <code class="gl-white-space-pre-wrap"> {{ branchName }} </code> + <code class="gl-white-space-pre-wrap">{{ branchName }}</code> <gl-form-input v-model="enteredBranchName" name="delete_branch_input" diff --git a/app/assets/javascripts/captcha/captcha_modal.vue b/app/assets/javascripts/captcha/captcha_modal.vue index b8b90b04beb..36aa098d5ff 100644 --- a/app/assets/javascripts/captcha/captcha_modal.vue +++ b/app/assets/javascripts/captcha/captcha_modal.vue @@ -107,7 +107,9 @@ export default { ref="modal" :modal-id="modalId" :title="__('Please solve the captcha')" - :action-cancel="{ text: __('Cancel') }" + :action-cancel="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + text: __('Cancel'), + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @shown="shown" @hide="hide" @hidden="$emit('hidden')" diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue index d70ade36fe9..dbc4565b19d 100644 --- a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue +++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue @@ -1,22 +1,48 @@ <script> -import { GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui'; +import { + GlAlert, + GlButton, + GlIcon, + GlLink, + GlLoadingIcon, + GlModal, + GlModalDirective, + GlPagination, + GlSprintf, + GlTable, + GlTooltipDirective, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import Api, { DEFAULT_PER_PAGE } from '~/api'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { __ } from '~/locale'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { __, s__, sprintf } from '~/locale'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { + GlAlert, + GlButton, + GlIcon, GlLink, GlLoadingIcon, + GlModal, GlPagination, + GlSprintf, GlTable, TimeagoTooltip, }, - inject: ['projectId'], + directives: { + GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, + }, + inject: ['projectId', 'admin', 'fileSizeLimit'], docsLink: helpPagePath('ci/secure_files/index'), DEFAULT_PER_PAGE, i18n: { + deleteLabel: __('Delete File'), + uploadLabel: __('Upload File'), + uploadingLabel: __('Uploading...'), pagination: { next: __('Next'), prev: __('Prev'), @@ -26,27 +52,45 @@ export default { 'Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.', ), moreInformation: __('More information'), + uploadErrorMessages: { + duplicate: __('A file with this name already exists.'), + tooLarge: __('File too large. Secure Files must be less than %{limit} MB.'), + }, + deleteModalTitle: s__('SecureFiles|Delete %{name}?'), + deleteModalMessage: s__( + 'SecureFiles|Secure File %{name} will be permanently deleted. Are you sure?', + ), + deleteModalButton: s__('SecureFiles|Delete secure file'), }, + deleteModalId: 'deleteModalId', data() { return { page: 1, totalItems: 0, loading: false, + uploading: false, + error: false, + errorMessage: null, projectSecureFiles: [], + deleteModalFileId: null, + deleteModalFileName: null, }; }, fields: [ { key: 'name', label: __('Filename'), - }, - { - key: 'permissions', - label: __('Permissions'), + tdClass: 'gl-vertical-align-middle!', }, { key: 'created_at', label: __('Uploaded'), + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'actions', + label: '', + tdClass: 'gl-text-right gl-vertical-align-middle!', }, ], computed: { @@ -63,6 +107,18 @@ export default { this.getProjectSecureFiles(); }, methods: { + async deleteSecureFile(secureFileId) { + this.loading = true; + this.error = false; + try { + await Api.deleteProjectSecureFile(this.projectId, secureFileId); + this.getProjectSecureFiles(); + } catch (error) { + Sentry.captureException(error); + this.error = true; + this.errorMessage = error; + } + }, async getProjectSecureFiles(page) { this.loading = true; const response = await Api.projectSecureFiles(this.projectId, { page }); @@ -72,6 +128,48 @@ export default { this.projectSecureFiles = response.data; this.loading = false; + this.uploading = false; + }, + async uploadSecureFile() { + this.error = null; + this.uploading = true; + const [file] = this.$refs.fileUpload.files; + try { + await Api.uploadProjectSecureFile(this.projectId, this.uploadFormData(file)); + this.getProjectSecureFiles(); + } catch (error) { + this.error = true; + this.errorMessage = this.formattedErrorMessage(error); + this.uploading = false; + } + }, + formattedErrorMessage(error) { + let message = ''; + if (error?.response?.data?.message?.name) { + message = this.$options.i18n.uploadErrorMessages.duplicate; + } else if (error.response.status === httpStatusCodes.PAYLOAD_TOO_LARGE) { + message = sprintf(this.$options.i18n.uploadErrorMessages.tooLarge, { + limit: this.fileSizeLimit, + }); + } else { + Sentry.captureException(error); + message = error; + } + return message; + }, + loadFileSelctor() { + this.$refs.fileUpload.click(); + }, + setDeleteModalData(secureFile) { + this.deleteModalFileId = secureFile.id; + this.deleteModalFileName = secureFile.name; + }, + uploadFormData(file) { + const formData = new FormData(); + formData.append('name', file.name); + formData.append('file', file); + + return formData; }, }, }; @@ -79,16 +177,51 @@ export default { <template> <div> - <h1 data-testid="title" class="gl-font-size-h1 gl-mt-3 gl-mb-0">{{ $options.i18n.title }}</h1> + <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = null"> + {{ errorMessage }} + </gl-alert> + <div class="row"> + <div class="col-md-12 col-lg-6 gl-display-flex"> + <div class="gl-flex-direction-column gl-flex-wrap"> + <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-0"> + {{ $options.i18n.title }} + </h1> + </div> + </div> + + <div class="col-md-12 col-lg-6"> + <div class="gl-display-flex gl-flex-wrap gl-justify-content-end"> + <gl-button v-if="admin" class="gl-mt-3" variant="info" @click="loadFileSelctor"> + <span v-if="uploading"> + <gl-loading-icon size="sm" class="gl-my-5" inline /> + {{ $options.i18n.uploadingLabel }} + </span> + <span v-else> + <gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }} + </span> + </gl-button> + <input + id="file-upload" + ref="fileUpload" + type="file" + class="hidden" + data-qa-selector="file_upload_field" + @change="uploadSecureFile" + /> + </div> + </div> + </div> - <p> - <span data-testid="info-message" class="gl-mr-2"> - {{ $options.i18n.overviewMessage }} - <gl-link :href="$options.docsLink" target="_blank">{{ - $options.i18n.moreInformation - }}</gl-link> - </span> - </p> + <div class="row"> + <div class="col-md-12 col-lg-12 gl-my-4"> + <span data-testid="info-message"> + {{ $options.i18n.overviewMessage }} + <gl-link :href="$options.docsLink" target="_blank">{{ + $options.i18n.moreInformation + }}</gl-link> + </span> + </div> + </div> <gl-table :busy="loading" @@ -112,14 +245,23 @@ export default { {{ item.name }} </template> - <template #cell(permissions)="{ item }"> - {{ item.permissions }} - </template> - <template #cell(created_at)="{ item }"> <timeago-tooltip :time="item.created_at" /> </template> + + <template #cell(actions)="{ item }"> + <gl-button + v-if="admin" + v-gl-modal="$options.deleteModalId" + v-gl-tooltip.hover.top="$options.i18n.deleteLabel" + variant="danger" + icon="remove" + :aria-label="$options.i18n.deleteLabel" + @click="setDeleteModalData(item)" + /> + </template> </gl-table> + <gl-pagination v-if="!loading" v-model="page" @@ -129,5 +271,25 @@ export default { :prev-text="$options.i18n.pagination.prev" align="center" /> + + <gl-modal + :ref="$options.deleteModalId" + :modal-id="$options.deleteModalId" + title-tag="h4" + category="primary" + :ok-title="$options.i18n.deleteModalButton" + ok-variant="danger" + @ok="deleteSecureFile(deleteModalFileId)" + > + <template #modal-title> + <gl-sprintf :message="$options.i18n.deleteModalTitle"> + <template #name>{{ deleteModalFileName }}</template> + </gl-sprintf> + </template> + + <gl-sprintf :message="$options.i18n.deleteModalMessage"> + <template #name>{{ deleteModalFileName }}</template> + </gl-sprintf> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/ci_secure_files/index.js b/app/assets/javascripts/ci_secure_files/index.js index 18b4ac6866e..3944286dc60 100644 --- a/app/assets/javascripts/ci_secure_files/index.js +++ b/app/assets/javascripts/ci_secure_files/index.js @@ -1,14 +1,19 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import SecureFilesList from './components/secure_files_list.vue'; export const initCiSecureFiles = (selector = '#js-ci-secure-files') => { const containerEl = document.querySelector(selector); const { projectId } = containerEl.dataset; + const { admin } = containerEl.dataset; + const { fileSizeLimit } = containerEl.dataset; return new Vue({ el: containerEl, provide: { projectId, + admin: parseBoolean(admin), + fileSizeLimit, }, render(createElement) { return createElement(SecureFilesList); diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index be2366108b3..3af89dc4a2c 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -26,6 +26,7 @@ import { AWS_TIP_DISMISSED_COOKIE_NAME, AWS_TIP_MESSAGE, CONTAINS_VARIABLE_REFERENCE_MESSAGE, + ENVIRONMENT_SCOPE_LINK_TITLE, EVENT_LABEL, EVENT_ACTION, } from '../constants'; @@ -40,6 +41,7 @@ export default { tokenList: awsTokenList, awsTipMessage: AWS_TIP_MESSAGE, containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, + environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, components: { CiEnvironmentsDropdown, GlAlert, @@ -81,6 +83,7 @@ export default { 'containsVariableReferenceLink', 'protectedEnvironmentVariablesLink', 'maskedEnvironmentVariablesLink', + 'environmentScopeLink', ]), ...mapComputed( [ @@ -109,7 +112,7 @@ export default { return regex.test(this.variable.secret_value); }, containsVariableReference() { - const regex = RegExp(/\$/); + const regex = /\$/; return regex.test(this.variable.secret_value); }, displayMaskedError() { @@ -278,12 +281,18 @@ export default { <gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" /> </gl-form-group> - <gl-form-group - :label="__('Environment scope')" - label-for="ci-variable-env" - class="w-50" - data-testid="environment-scope" - > + <gl-form-group label-for="ci-variable-env" class="w-50" data-testid="environment-scope"> + <template #label> + {{ __('Environment scope') }} + <gl-link + :title="$options.environmentScopeLinkTitle" + :href="environmentScopeLink" + target="_blank" + data-testid="environment-scope-link" + > + <gl-icon name="question" :size="12" /> + </gl-link> + </template> <ci-environments-dropdown v-if="scopedVariablesAvailable" class="w-100" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index 61636b389da..f078234829a 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -179,10 +179,7 @@ export default { </p> </template> </gl-table> - <div - class="ci-variable-actions gl-display-flex" - :class="{ 'gl-justify-content-center': isTableEmpty }" - > + <div class="ci-variable-actions gl-display-flex gl-mt-5"> <gl-button v-gl-modal-directive="$options.modalId" class="gl-mr-3" diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index 663a912883b..fa55b4d9e77 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -31,3 +31,5 @@ export const AWS_TOKEN_CONSTANTS = [AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_S export const CONTAINS_VARIABLE_REFERENCE_MESSAGE = __( 'Values that contain the %{codeStart}$%{codeEnd} character can be considered a variable reference and expanded. %{docsLinkStart}Learn more.%{docsLinkEnd}', ); + +export const ENVIRONMENT_SCOPE_LINK_TITLE = __('Learn more'); diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js index 7c40f8134d4..f771751194c 100644 --- a/app/assets/javascripts/ci_variable_list/index.js +++ b/app/assets/javascripts/ci_variable_list/index.js @@ -17,6 +17,7 @@ const mountCiVariableListApp = (containerEl) => { containsVariableReferenceLink, protectedEnvironmentVariablesLink, maskedEnvironmentVariablesLink, + environmentScopeLink, } = containerEl.dataset; const isGroup = parseBoolean(group); const isProtectedByDefault = parseBoolean(protectedByDefault); @@ -34,6 +35,7 @@ const mountCiVariableListApp = (containerEl) => { containsVariableReferenceLink, protectedEnvironmentVariablesLink, maskedEnvironmentVariablesLink, + environmentScopeLink, }); return new Vue({ diff --git a/app/assets/javascripts/clone_panel.js b/app/assets/javascripts/clone_panel.js index ec831a77bde..79280c13f0f 100644 --- a/app/assets/javascripts/clone_panel.js +++ b/app/assets/javascripts/clone_panel.js @@ -17,7 +17,12 @@ export default function initClonePanel() { $('a', $cloneOptions).on('click', (e) => { const $this = $(e.currentTarget); const url = $this.attr('href'); - if (url && (url.startsWith('vscode://') || url.startsWith('xcode://'))) { + if ( + url && + (url.startsWith('vscode://') || + url.startsWith('xcode://') || + url.startsWith('jetbrains://')) + ) { // Clone with "..." should open like a normal link return; } diff --git a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue index 6567ce203bc..18c6503bfb2 100644 --- a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue +++ b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue @@ -28,17 +28,17 @@ export default { }, i18n: { emptyText: s__( - 'ClusterAgents|See Agent activity updates such as tokens created or revoked and clusters connected or not connected.', + 'ClusterAgents|See agent activity updates, like tokens created or revoked and clusters connected or not connected.', ), - emptyTooltip: s__('ClusterAgents|What is GitLab Agent activity?'), + emptyTooltip: s__('ClusterAgents|What is agent activity?'), error: s__( - 'ClusterAgents|An error occurred while retrieving GitLab Agent activity. Reload the page to try again.', + 'ClusterAgents|An error occurred while retrieving agent activity. Reload the page to try again.', ), today: __('Today'), yesterday: __('Yesterday'), }, - emptyHelpLink: helpPagePath('user/clusters/agent/install/index', { - anchor: 'view-agent-activity', + emptyHelpLink: helpPagePath('user/clusters/agent/work_with_agent', { + anchor: 'view-an-agents-activity-information', }), borderClasses: 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100', apollo: { @@ -68,8 +68,8 @@ export default { }, emptyStateTitle() { return n__( - "ClusterAgents|There's no activity from the past day", - "ClusterAgents|There's no activity from the past %d days", + 'ClusterAgents|No activity occurred in the past day', + 'ClusterAgents|No activity occurred in the past %d days', EVENTS_STORED_DAYS, ); }, @@ -124,7 +124,7 @@ export default { <template> <div> - <gl-loading-icon v-if="isLoading" size="md" /> + <gl-loading-icon v-if="isLoading" size="lg" /> <div v-else-if="hasEvents"> <div diff --git a/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue b/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue new file mode 100644 index 00000000000..7d36cbb170d --- /dev/null +++ b/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue @@ -0,0 +1,201 @@ +<script> +import { + GlButton, + GlModalDirective, + GlTooltip, + GlModal, + GlFormGroup, + GlFormInput, + GlSprintf, +} from '@gitlab/ui'; +import { s__, __, sprintf } from '~/locale'; +import { REVOKE_TOKEN_MODAL_ID, TOKEN_STATUS_ACTIVE } from '../constants'; +import revokeAgentToken from '../graphql/mutations/revoke_token.mutation.graphql'; +import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql'; +import { removeTokenFromStore } from '../graphql/cache_update'; + +export default { + components: { + GlButton, + GlTooltip, + GlModal, + GlFormGroup, + GlFormInput, + GlSprintf, + }, + directives: { + GlModalDirective, + }, + inject: ['agentName', 'projectPath', 'canAdminCluster'], + props: { + token: { + required: true, + type: Object, + validator: (value) => ['id', 'name'].every((prop) => value[prop]), + }, + cursor: { + required: true, + type: Object, + }, + }, + i18n: { + revokeButton: s__('ClusterAgents|Revoke token'), + dropdownDisabledHint: s__( + 'ClusterAgents|Requires a Maintainer or greater role to perform this action', + ), + modalTitle: s__('ClusterAgents|Revoke access token?'), + modalBody: s__( + 'ClusterAgents|Are you sure you want to revoke this token? You cannot undo this action.', + ), + modalInputLabel: s__('ClusterAgents|To revoke the token, type %{name} to confirm:'), + modalCancel: __('Cancel'), + successMessage: s__('ClusterAgents|%{name} successfully revoked'), + defaultError: __('An error occurred. Please try again.'), + }, + data() { + return { + loading: false, + error: null, + revokeConfirmText: null, + tokenName: null, + variables: { + agentName: this.agentName, + projectPath: this.projectPath, + tokenStatus: TOKEN_STATUS_ACTIVE, + ...this.cursor, + }, + }; + }, + computed: { + revokeBtnDisabled() { + return this.loading || !this.canAdminCluster; + }, + modalId() { + return sprintf(REVOKE_TOKEN_MODAL_ID, { + tokenName: this.token.name, + }); + }, + primaryModalProps() { + return { + text: this.$options.i18n.revokeButton, + attributes: [ + { disabled: this.loading || this.disableModalSubmit, loading: this.loading }, + { variant: 'danger' }, + ], + }; + }, + cancelModalProps() { + return { + text: this.$options.i18n.modalCancel, + attributes: [], + }; + }, + disableModalSubmit() { + return this.revokeConfirmText !== this.token.name; + }, + }, + methods: { + async revokeToken() { + if (this.disableModalSubmit || this.loading) { + return; + } + + this.loading = true; + this.error = null; + this.tokenName = this.token.name; + + try { + const { errors } = await this.revokeTokenMutation(); + + if (errors.length) { + throw new Error(errors[0]); + } + } catch (error) { + this.error = error?.message || this.$options.i18n.defaultError; + } finally { + this.loading = false; + const successMessage = sprintf(this.$options.i18n.successMessage, { + name: this.tokenName, + }); + + this.$toast.show(this.error || successMessage); + + this.hideModal(); + } + }, + revokeTokenMutation() { + return this.$apollo + .mutate({ + mutation: revokeAgentToken, + variables: { + input: { + id: this.token.id, + }, + }, + update: (store) => { + removeTokenFromStore(store, this.token, getClusterAgentQuery, this.variables); + }, + }) + + .then(({ data: { clusterAgentTokenRevoke } }) => { + return clusterAgentTokenRevoke; + }); + }, + resetModal() { + this.loading = false; + this.error = null; + this.revokeConfirmText = null; + }, + hideModal() { + this.resetModal(); + this.$refs.modal.hide(); + }, + }, +}; +</script> + +<template> + <div> + <div ref="revokeToken" class="gl-display-inline-block"> + <gl-button + v-gl-modal-directive="modalId" + icon="remove" + category="secondary" + variant="danger" + :disabled="revokeBtnDisabled" + :title="$options.i18n.revokeButton" + :aria-label="$options.i18n.revokeButton" + /> + + <gl-tooltip + v-if="!canAdminCluster" + :target="() => $refs.revokeToken" + :title="$options.i18n.dropdownDisabledHint" + /> + </div> + + <gl-modal + ref="modal" + :modal-id="modalId" + :title="$options.i18n.modalTitle" + :action-primary="primaryModalProps" + :action-cancel="cancelModalProps" + size="sm" + @primary="revokeToken" + @hide="hideModal" + > + <p>{{ $options.i18n.modalBody }}</p> + + <gl-form-group> + <template #label> + <gl-sprintf :message="$options.i18n.modalInputLabel"> + <template #name> + <code>{{ token.name }}</code> + </template> + </gl-sprintf> + </template> + <gl-form-input v-model="revokeConfirmText" @keydown.enter="revokeToken" /> + </gl-form-group> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue index 5df3e0811a5..e3de8339325 100644 --- a/app/assets/javascripts/clusters/agents/components/show.vue +++ b/app/assets/javascripts/clusters/agents/components/show.vue @@ -140,7 +140,7 @@ export default { </span> </template> - <gl-loading-icon v-if="isLoading" size="md" class="gl-m-3" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> <div v-else> <token-table :tokens="tokens" :cluster-agent-id="clusterAgent.id" :cursor="cursor" /> diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue index fbb39c28d78..9e64c9da712 100644 --- a/app/assets/javascripts/clusters/agents/components/token_table.vue +++ b/app/assets/javascripts/clusters/agents/components/token_table.vue @@ -3,6 +3,7 @@ import { GlEmptyState, GlTable, GlTooltip, GlTruncate } from '@gitlab/ui'; import { s__ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import CreateTokenButton from './create_token_button.vue'; +import RevokeTokenButton from './revoke_token_button.vue'; export default { components: { @@ -12,6 +13,7 @@ export default { GlTruncate, TimeAgoTooltip, CreateTokenButton, + RevokeTokenButton, }, i18n: { createdBy: s__('ClusterAgents|Created by'), @@ -66,6 +68,11 @@ export default { label: this.$options.i18n.description, tdAttr: { 'data-testid': 'agent-token-description' }, }, + { + key: 'actions', + label: '', + tdAttr: { 'data-testid': 'agent-token-revoke' }, + }, ]; }, }, @@ -119,6 +126,10 @@ export default { </gl-tooltip> </div> </template> + + <template #cell(actions)="{ item }"> + <revoke-token-button :token="item" :cluster-agent-id="clusterAgentId" :cursor="cursor" /> + </template> </gl-table> </div> diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js index 50d8f5e9e40..962fa243903 100644 --- a/app/assets/javascripts/clusters/agents/constants.js +++ b/app/assets/javascripts/clusters/agents/constants.js @@ -44,3 +44,5 @@ export const EVENT_ACTIONS_OPEN = 'open_modal'; export const EVENT_ACTIONS_CLICK = 'click_button'; export const TOKEN_NAME_LIMIT = 255; + +export const REVOKE_TOKEN_MODAL_ID = 'revoke-token-%{tokenName}'; diff --git a/app/assets/javascripts/clusters/agents/graphql/cache_update.js b/app/assets/javascripts/clusters/agents/graphql/cache_update.js index 0219c4150eb..8db79c82708 100644 --- a/app/assets/javascripts/clusters/agents/graphql/cache_update.js +++ b/app/assets/javascripts/clusters/agents/graphql/cache_update.js @@ -22,3 +22,25 @@ export function addAgentTokenToStore(store, clusterAgentTokenCreate, query, vari }); } } + +export function removeTokenFromStore(store, revokeToken, query, variables) { + if (!hasErrors(revokeToken)) { + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, (draftData) => { + draftData.project.clusterAgent.tokens.nodes = draftData.project.clusterAgent.tokens.nodes.filter( + ({ id }) => id !== revokeToken.id, + ); + draftData.project.clusterAgent.tokens.count -= 1; + }); + + store.writeQuery({ + query, + variables, + data, + }); + } +} diff --git a/app/assets/javascripts/clusters/agents/graphql/mutations/revoke_token.mutation.graphql b/app/assets/javascripts/clusters/agents/graphql/mutations/revoke_token.mutation.graphql new file mode 100644 index 00000000000..6f1c6a66690 --- /dev/null +++ b/app/assets/javascripts/clusters/agents/graphql/mutations/revoke_token.mutation.graphql @@ -0,0 +1,5 @@ +mutation revokeAgentToken($input: ClusterAgentTokenRevokeInput!) { + clusterAgentTokenRevoke(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/clusters/components/new_cluster.vue b/app/assets/javascripts/clusters/components/new_cluster.vue index 8f3e2916270..41a33a8459f 100644 --- a/app/assets/javascripts/clusters/components/new_cluster.vue +++ b/app/assets/javascripts/clusters/components/new_cluster.vue @@ -1,6 +1,6 @@ <script> import { GlLink, GlSprintf } from '@gitlab/ui'; -import { mapState } from 'vuex'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; export default { @@ -10,13 +10,11 @@ export default { 'ClusterIntegration|Enter details about your cluster. %{linkStart}How do I use a certificate to connect to my cluster?%{linkEnd}', ), }, + clusterConnectHelpPath: helpPagePath('user/project/clusters/add_existing_cluster'), components: { GlLink, GlSprintf, }, - computed: { - ...mapState(['clusterConnectHelpPath']), - }, }; </script> @@ -26,7 +24,7 @@ export default { <p> <gl-sprintf :message="$options.i18n.information"> <template #link="{ content }"> - <gl-link :href="clusterConnectHelpPath" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.clusterConnectHelpPath">{{ content }}</gl-link> </template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue index 98db620e3ab..dca89133931 100644 --- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue +++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue @@ -159,7 +159,7 @@ export default { ) }}</span> <template #modal-footer> - <gl-button variant="secondary" @click="handleCancel">{{ __('Cancel') }}</gl-button> + <gl-button @click="handleCancel">{{ __('Cancel') }}</gl-button> <template v-if="confirmCleanup"> <gl-button :disabled="!canSubmit" diff --git a/app/assets/javascripts/clusters/forms/components/integration_form.vue b/app/assets/javascripts/clusters/forms/components/integration_form.vue index 3f61a1b18a7..b2a8381f937 100644 --- a/app/assets/javascripts/clusters/forms/components/integration_form.vue +++ b/app/assets/javascripts/clusters/forms/components/integration_form.vue @@ -140,7 +140,7 @@ export default { <div v-if="editable" class="form group gl-display-flex gl-justify-content-end"> <gl-button category="primary" - variant="success" + variant="confirm" type="submit" :disabled="!canSubmit" :aria-disabled="!canSubmit" diff --git a/app/assets/javascripts/create_cluster/gke_cluster_namespace/index.js b/app/assets/javascripts/clusters/gke_cluster_namespace/index.js index 2b3dfb99328..2b3dfb99328 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster_namespace/index.js +++ b/app/assets/javascripts/clusters/gke_cluster_namespace/index.js diff --git a/app/assets/javascripts/clusters/new_cluster.js b/app/assets/javascripts/clusters/new_cluster.js index 71f585fd307..4df6872bcc1 100644 --- a/app/assets/javascripts/clusters/new_cluster.js +++ b/app/assets/javascripts/clusters/new_cluster.js @@ -1,17 +1,15 @@ import Vue from 'vue'; import NewCluster from './components/new_cluster.vue'; -import { createStore } from './stores/new_cluster'; export default () => { - const entryPoint = document.querySelector('#js-cluster-new'); + const el = document.querySelector('#js-cluster-new'); - if (!entryPoint) { + if (!el) { return null; } return new Vue({ - el: '#js-cluster-new', - store: createStore(entryPoint.dataset), + el, render(createElement) { return createElement(NewCluster); }, diff --git a/app/assets/javascripts/clusters/stores/new_cluster/index.js b/app/assets/javascripts/clusters/stores/new_cluster/index.js deleted file mode 100644 index 87f1c05fdf9..00000000000 --- a/app/assets/javascripts/clusters/stores/new_cluster/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import state from './state'; - -Vue.use(Vuex); - -export const createStore = (initialState) => - new Vuex.Store({ - state: state(initialState), - }); - -export default createStore; diff --git a/app/assets/javascripts/clusters/stores/new_cluster/state.js b/app/assets/javascripts/clusters/stores/new_cluster/state.js deleted file mode 100644 index 1ca1ac8de18..00000000000 --- a/app/assets/javascripts/clusters/stores/new_cluster/state.js +++ /dev/null @@ -1,3 +0,0 @@ -export default (initialState = {}) => ({ - clusterConnectHelpPath: initialState.clusterConnectHelpPath, -}); diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue index 2decdb5307b..496baf8cb08 100644 --- a/app/assets/javascripts/clusters_list/components/agent_table.vue +++ b/app/assets/javascripts/clusters_list/components/agent_table.vue @@ -39,7 +39,7 @@ export default { configHelpLink: helpPagePath('user/clusters/agent/install/index', { anchor: 'create-an-agent-configuration-file', }), - inject: ['gitlabVersion'], + inject: ['gitlabVersion', 'kasVersion'], props: { agents: { required: true, @@ -102,6 +102,9 @@ export default { return { ...agent, versions }; }); }, + serverVersion() { + return this.kasVersion || this.gitlabVersion; + }, }, methods: { getStatusCellId(item) { @@ -135,12 +138,12 @@ export default { if (!agent.versions.length) return false; const [agentMajorVersion, agentMinorVersion] = this.getAgentVersionString(agent).split('.'); - const [gitlabMajorVersion, gitlabMinorVersion] = this.gitlabVersion.split('.'); + const [serverMajorVersion, serverMinorVersion] = this.serverVersion.split('.'); - const majorVersionMismatch = agentMajorVersion !== gitlabMajorVersion; + const majorVersionMismatch = agentMajorVersion !== serverMajorVersion; // We should warn user if their current GitLab and agent versions are more than 1 minor version apart: - const minorVersionMismatch = Math.abs(agentMinorVersion - gitlabMinorVersion) > 1; + const minorVersionMismatch = Math.abs(agentMinorVersion - serverMinorVersion) > 1; return majorVersionMismatch || minorVersionMismatch; }, @@ -165,8 +168,6 @@ export default { :items="agentsList" :fields="fields" stacked="md" - head-variant="white" - thead-class="gl-border-b-solid gl-border-b-2 gl-border-b-gray-100" class="gl-mb-4!" data-testid="cluster-agent-list-table" > @@ -242,7 +243,7 @@ export default { <p class="gl-mb-0"> <gl-sprintf :message="$options.i18n.versionOutdatedText"> - <template #version>{{ gitlabVersion }}</template> + <template #version>{{ serverVersion }}</template> </gl-sprintf> <gl-link :href="$options.versionUpdateLink" class="gl-font-sm"> {{ $options.i18n.viewDocsText }}</gl-link @@ -255,7 +256,7 @@ export default { <p v-else-if="isVersionOutdated(item)" class="gl-mb-0"> <gl-sprintf :message="$options.i18n.versionOutdatedText"> - <template #version>{{ gitlabVersion }}</template> + <template #version>{{ serverVersion }}</template> </gl-sprintf> <gl-link :href="$options.versionUpdateLink" class="gl-font-sm"> {{ $options.i18n.viewDocsText }}</gl-link diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue index 89b18ed6d06..8a4a81d3e96 100644 --- a/app/assets/javascripts/clusters_list/components/agents.vue +++ b/app/assets/javascripts/clusters_list/components/agents.vue @@ -175,7 +175,7 @@ export default { </script> <template> - <gl-loading-icon v-if="isLoading" size="md" /> + <gl-loading-icon v-if="isLoading" size="lg" /> <section v-else-if="agentList"> <div v-if="agentList.length"> diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 59cfdde731d..fb3c8ff66b0 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -224,7 +224,7 @@ export default { </script> <template> - <gl-loading-icon v-if="loadingClusters" size="md" /> + <gl-loading-icon v-if="loadingClusters" size="lg" /> <section v-else> <ancestor-notice /> @@ -235,8 +235,6 @@ export default { :fields="fields" fixed stacked="md" - head-variant="white" - thead-class="gl-border-b-solid gl-border-b-2 gl-border-b-gray-100" class="qa-clusters-table gl-mb-4!" data-testid="cluster_list_table" > diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue index 8fd759bd3e9..2675d46dd16 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlModalDirective, GlTooltip } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlDropdownItem, GlModalDirective, GlTooltip } from '@gitlab/ui'; import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '../constants'; @@ -7,6 +7,7 @@ export default { i18n: CLUSTERS_ACTIONS, INSTALL_AGENT_MODAL_ID, components: { + GlButton, GlDropdown, GlDropdownItem, GlTooltip, @@ -15,7 +16,6 @@ export default { GlModalDirective, }, inject: [ - 'newClusterPath', 'addClusterPath', 'newClusterDocsPath', 'canAddCluster', @@ -42,20 +42,59 @@ export default { } return this.addClusterPath; }, + actionItems() { + const createCluster = { + href: this.newClusterDocsPath, + title: this.$options.i18n.createCluster, + testid: 'create-cluster-link', + }; + const connectCluster = { + href: this.addClusterPath, + title: this.$options.i18n.connectClusterCertificate, + testid: 'connect-cluster-link', + }; + const actions = []; + + if (this.displayClusterAgents) { + actions.push(createCluster); + } + if (this.displayClusterAgents && this.certificateBasedClustersEnabled) { + actions.push(connectCluster); + } + + return actions; + }, + }, + methods: { + getTooltipTarget() { + return this.actionItems.length ? this.$refs.actions.$el : this.$refs.actionsContainer; + }, }, }; </script> <template> - <div class="nav-controls gl-ml-auto"> + <div ref="actionsContainer" class="nav-controls gl-ml-auto"> <gl-tooltip v-if="!canAddCluster" - :target="() => $refs.dropdown.$el" - :title="$options.i18n.dropdownDisabledHint" + :target="() => getTooltipTarget()" + :title="$options.i18n.actionsDisabledHint" /> + <gl-button + v-if="!actionItems.length" + data-qa-selector="clusters_actions_button" + category="primary" + variant="confirm" + :disabled="!canAddCluster" + :href="defaultActionUrl" + > + {{ defaultActionText }} + </gl-button> + <gl-dropdown - ref="dropdown" + v-else + ref="actions" v-gl-modal-directive="shouldTriggerModal && $options.INSTALL_AGENT_MODAL_ID" data-qa-selector="clusters_actions_button" category="primary" @@ -67,31 +106,13 @@ export default { right > <gl-dropdown-item - v-if="displayClusterAgents" - :href="newClusterDocsPath" - data-testid="create-cluster-link" - @click.stop - > - {{ $options.i18n.createCluster }} - </gl-dropdown-item> - - <template v-if="displayClusterAgents && certificateBasedClustersEnabled"> - <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop> - {{ $options.i18n.createClusterCertificate }} - </gl-dropdown-item> - - <gl-dropdown-item :href="addClusterPath" data-testid="connect-cluster-link" @click.stop> - {{ $options.i18n.connectClusterCertificate }} - </gl-dropdown-item> - </template> - - <gl-dropdown-item - v-if="certificateBasedClustersEnabled && !displayClusterAgents" - :href="newClusterPath" - data-testid="new-cluster-link" + v-for="action in actionItems" + :key="action.title" + :href="action.href" + :data-testid="action.testid" @click.stop > - {{ $options.i18n.createClusterDeprecated }} + {{ action.title }} </gl-dropdown-item> </gl-dropdown> </div> diff --git a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue index 73ca804e111..d831d79b994 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue @@ -89,7 +89,7 @@ export default { </script> <template> <div> - <gl-loading-icon v-if="isLoading" size="md" /> + <gl-loading-icon v-if="isLoading" size="lg" /> <div v-show="!isLoading" data-testid="clusters-cards-container"> <gl-card header-class="gl-bg-white gl-display-flex gl-align-items-center gl-justify-content-space-between gl-py-4" diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 4a168e811aa..10e71513065 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -234,11 +234,9 @@ export const CLUSTERS_ACTIONS = { connectCluster: s__('ClusterAgents|Connect a cluster'), connectWithAgent: s__('ClusterAgents|Connect a cluster (agent)'), connectClusterDeprecated: s__('ClusterAgents|Connect a cluster (deprecated)'), - createClusterDeprecated: s__('ClusterAgents|Create a cluster (deprecated)'), createCluster: s__('ClusterAgents|Create a cluster'), - createClusterCertificate: s__('ClusterAgents|Create a cluster (certificate - deprecated)'), connectClusterCertificate: s__('ClusterAgents|Connect a cluster (certificate - deprecated)'), - dropdownDisabledHint: s__( + actionsDisabledHint: s__( 'ClusterAgents|Requires a Maintainer or greater role to perform these actions', ), }; diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js index f6dfb96ffd9..cd334d80e9c 100644 --- a/app/assets/javascripts/clusters_list/index.js +++ b/app/assets/javascripts/clusters_list/index.js @@ -23,7 +23,6 @@ export default () => { defaultBranchName, projectPath, kasAddress, - newClusterPath, addClusterPath, newClusterDocsPath, emptyStateHelpText, @@ -31,6 +30,7 @@ export default () => { canAddCluster, canAdminCluster, gitlabVersion, + kasVersion, displayClusterAgents, certificateBasedClustersEnabled, } = el.dataset; @@ -42,7 +42,6 @@ export default () => { emptyStateImage, projectPath, kasAddress, - newClusterPath, addClusterPath, newClusterDocsPath, emptyStateHelpText, @@ -50,6 +49,7 @@ export default () => { canAddCluster: parseBoolean(canAddCluster), canAdminCluster: parseBoolean(canAdminCluster), gitlabVersion, + kasVersion, displayClusterAgents: parseBoolean(displayClusterAgents), certificateBasedClustersEnabled: parseBoolean(certificateBasedClustersEnabled), }, diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index b92f3d5a97b..29530ddb7a2 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -212,7 +212,6 @@ export default { <template #table-header-actions> <div v-if="canRenderPipelineButton" class="gl-text-right"> <gl-button - variant="confirm" data-testid="run_pipeline_button" :loading="state.isRunningMergeRequestPipeline" @click="tryRunPipeline" diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue index 32d9159ee34..af049738016 100644 --- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue +++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue @@ -58,6 +58,7 @@ export default { } } else if (this.createBtn) { this.createBtn.setAttribute('disabled', 'disabled'); + this.createBtn.classList.add('disabled'); } }, normalizeProjectData(data) { @@ -89,11 +90,6 @@ export default { if (this.warningText) { this.warningText.classList.remove('gl-display-none'); } - - if (this.createBtn) { - this.createBtn.classList.add('btn-warning'); - this.createBtn.classList.remove('btn-success'); - } }, }, i18n: { diff --git a/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue index 87f22a27856..518ddd7a09c 100644 --- a/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue @@ -8,11 +8,12 @@ import { GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; import { BubbleMenu } from '@tiptap/vue-2'; -import codeBlockLanguageLoader from '../services/code_block_language_loader'; -import CodeBlockHighlight from '../extensions/code_block_highlight'; -import Diagram from '../extensions/diagram'; -import Frontmatter from '../extensions/frontmatter'; -import EditorStateObserver from './editor_state_observer.vue'; +import { getParentByTagName } from '~/lib/utils/dom_utils'; +import codeBlockLanguageLoader from '../../services/code_block_language_loader'; +import CodeBlockHighlight from '../../extensions/code_block_highlight'; +import Diagram from '../../extensions/diagram'; +import Frontmatter from '../../extensions/frontmatter'; +import EditorStateObserver from '../editor_state_observer.vue'; const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; @@ -32,6 +33,7 @@ export default { inject: ['tiptapEditor'], data() { return { + codeBlockType: undefined, selectedLanguage: {}, filterTerm: '', filteredLanguages: [], @@ -50,47 +52,40 @@ export default { return CODE_BLOCK_NODE_TYPES.some((type) => editor.isActive(type)); }, - getSelectedLanguage() { - const { language } = this.tiptapEditor.getAttributes(this.getCodeBlockType()); + updateSelectedLanguage() { + this.codeBlockType = CODE_BLOCK_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type)); - this.selectedLanguage = codeBlockLanguageLoader.findLanguageBySyntax(language); + if (this.codeBlockType) { + const { language } = this.tiptapEditor.getAttributes(this.codeBlockType); + this.selectedLanguage = codeBlockLanguageLoader.findLanguageBySyntax(language); + } }, - async setSelectedLanguage(language) { - this.selectedLanguage = language; - - await codeBlockLanguageLoader.loadLanguages([language.syntax]); + copyCodeBlockText() { + const { view } = this.tiptapEditor; + const { from } = this.tiptapEditor.state.selection; + const node = getParentByTagName(view.domAtPos(from).node, 'pre'); - this.tiptapEditor.commands.setCodeBlock({ language: this.selectedLanguage.syntax }); + navigator.clipboard.writeText(node?.textContent || ''); }, - tippyOnBeforeUpdate(tippy, props) { - if (props.getReferenceClientRect) { - // eslint-disable-next-line no-param-reassign - props.getReferenceClientRect = () => { - const { view } = this.tiptapEditor; - const { from } = this.tiptapEditor.state.selection; + async applySelectedLanguage(language) { + this.selectedLanguage = language; - for (let { node } = view.domAtPos(from); node; node = node.parentElement) { - if (node.nodeName?.toLowerCase() === 'pre') { - return node.getBoundingClientRect(); - } - } + await codeBlockLanguageLoader.loadLanguage(language.syntax); - return new DOMRect(-1000, -1000, 0, 0); - }; - } + this.tiptapEditor.commands.setCodeBlock({ language: this.selectedLanguage.syntax }); }, - deleteCodeBlock() { - this.tiptapEditor.chain().focus().deleteNode(this.getCodeBlockType()).run(); + getReferenceClientRect() { + const { view } = this.tiptapEditor; + const { from } = this.tiptapEditor.state.selection; + const node = getParentByTagName(view.domAtPos(from).node, 'pre'); + return node?.getBoundingClientRect() || new DOMRect(-1000, -1000, 0, 0); }, - getCodeBlockType() { - return ( - CODE_BLOCK_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type)) || - CodeBlockHighlight.name - ); + deleteCodeBlock() { + this.tiptapEditor.chain().focus().deleteNode(this.codeBlockType).run(); }, }, }; @@ -98,15 +93,22 @@ export default { <template> <bubble-menu data-testid="code-block-bubble-menu" - class="gl-shadow gl-rounded-base" + class="gl-shadow gl-rounded-base gl-bg-white" :editor="tiptapEditor" plugin-key="bubbleMenuCodeBlock" :should-show="shouldShow" - :tippy-options="{ onBeforeUpdate: tippyOnBeforeUpdate }" + :tippy-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + getReferenceClientRect, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" > - <editor-state-observer @transaction="getSelectedLanguage"> + <editor-state-observer @transaction="updateSelectedLanguage"> <gl-button-group> - <gl-dropdown contenteditable="false" boundary="viewport" :text="selectedLanguage.label"> + <gl-dropdown + category="tertiary" + contenteditable="false" + boundary="viewport" + :text="selectedLanguage.label" + > <template #header> <gl-search-box-by-type v-model="filterTerm" @@ -125,7 +127,7 @@ export default { v-for="language in filteredLanguages" v-show="selectedLanguage.syntax !== language.syntax" :key="language.syntax" - @click="setSelectedLanguage(language)" + @click="applySelectedLanguage(language)" > {{ language.label }} </gl-dropdown-item> @@ -133,8 +135,20 @@ export default { <gl-button v-gl-tooltip variant="default" - category="primary" + category="tertiary" + size="medium" + data-testid="copy-code-block" + :aria-label="__('Copy code')" + :title="__('Copy code')" + icon="copy-to-clipboard" + @click="copyCodeBlockText" + /> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" size="medium" + data-testid="delete-code-block" :aria-label="__('Delete code block')" :title="__('Delete code block')" icon="remove" diff --git a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue index 103079534bc..e35fbf14de5 100644 --- a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue @@ -1,13 +1,16 @@ <script> import { GlButtonGroup } from '@gitlab/ui'; import { BubbleMenu } from '@tiptap/vue-2'; -import { BUBBLE_MENU_TRACKING_ACTION } from '../constants'; -import trackUIControl from '../services/track_ui_control'; -import Code from '../extensions/code'; -import CodeBlockHighlight from '../extensions/code_block_highlight'; -import Diagram from '../extensions/diagram'; -import Frontmatter from '../extensions/frontmatter'; -import ToolbarButton from './toolbar_button.vue'; +import { BUBBLE_MENU_TRACKING_ACTION } from '../../constants'; +import trackUIControl from '../../services/track_ui_control'; +import Image from '../../extensions/image'; +import Audio from '../../extensions/audio'; +import Video from '../../extensions/video'; +import Code from '../../extensions/code'; +import CodeBlockHighlight from '../../extensions/code_block_highlight'; +import Diagram from '../../extensions/diagram'; +import Frontmatter from '../../extensions/frontmatter'; +import ToolbarButton from '../toolbar_button.vue'; export default { components: { @@ -24,7 +27,15 @@ export default { shouldShow: ({ editor, from, to }) => { if (from === to) return false; - const exclude = [Code.name, CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; + const exclude = [ + Code.name, + CodeBlockHighlight.name, + Diagram.name, + Frontmatter.name, + Image.name, + Audio.name, + Video.name, + ]; return !exclude.some((type) => editor.isActive(type)); }, @@ -34,7 +45,7 @@ export default { <template> <bubble-menu data-testid="formatting-bubble-menu" - class="gl-shadow gl-rounded-base" + class="gl-shadow gl-rounded-base gl-bg-white" :editor="tiptapEditor" :should-show="shouldShow" > @@ -44,7 +55,7 @@ export default { content-type="bold" icon-name="bold" editor-command="toggleBold" - category="primary" + category="tertiary" size="medium" :label="__('Bold text')" @execute="trackToolbarControlExecution" @@ -54,7 +65,7 @@ export default { content-type="italic" icon-name="italic" editor-command="toggleItalic" - category="primary" + category="tertiary" size="medium" :label="__('Italic text')" @execute="trackToolbarControlExecution" @@ -64,7 +75,7 @@ export default { content-type="strike" icon-name="strikethrough" editor-command="toggleStrike" - category="primary" + category="tertiary" size="medium" :label="__('Strikethrough')" @execute="trackToolbarControlExecution" @@ -74,11 +85,24 @@ export default { content-type="code" icon-name="code" editor-command="toggleCode" - category="primary" + category="tertiary" size="medium" :label="__('Code')" @execute="trackToolbarControlExecution" /> + <toolbar-button + data-testid="link" + content-type="link" + icon-name="link" + editor-command="toggleLink" + :editor-command-params="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + href: '', + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + category="tertiary" + size="medium" + :label="__('Insert link')" + @execute="trackToolbarControlExecution" + /> </gl-button-group> </bubble-menu> </template> diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link.vue new file mode 100644 index 00000000000..dae0bc63b5a --- /dev/null +++ b/app/assets/javascripts/content_editor/components/bubble_menus/link.vue @@ -0,0 +1,189 @@ +<script> +import { + GlLink, + GlForm, + GlFormGroup, + GlFormInput, + GlButton, + GlButtonGroup, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { BubbleMenu } from '@tiptap/vue-2'; +import Link from '../../extensions/link'; +import EditorStateObserver from '../editor_state_observer.vue'; + +export default { + components: { + BubbleMenu, + GlForm, + GlFormGroup, + GlFormInput, + GlLink, + GlButton, + GlButtonGroup, + EditorStateObserver, + }, + directives: { + GlTooltip, + }, + inject: ['tiptapEditor', 'contentEditor'], + data() { + return { + linkHref: undefined, + linkCanonicalSrc: undefined, + linkTitle: undefined, + + isEditing: false, + }; + }, + watch: { + linkCanonicalSrc(value) { + if (!value) this.isEditing = true; + }, + }, + methods: { + shouldShow() { + const shouldShow = this.tiptapEditor.isActive(Link.name); + + if (!shouldShow) this.isEditing = false; + + return shouldShow; + }, + + startEditingLink() { + // select the entire link + this.tiptapEditor.chain().focus().extendMarkRange(Link.name).run(); + + this.isEditing = true; + }, + + async endEditingLink() { + this.isEditing = false; + + this.linkHref = await this.contentEditor.resolveUrl(this.linkCanonicalSrc); + + if (!this.linkCanonicalSrc && !this.linkHref) { + this.removeLink(); + } + }, + + cancelEditingLink() { + this.endEditingLink(); + this.updateLinkToState(); + }, + + async saveEditedLink() { + if (!this.linkCanonicalSrc) { + this.removeLink(); + } else { + this.tiptapEditor + .chain() + .focus() + .extendMarkRange(Link.name) + .updateAttributes(Link.name, { + href: this.linkCanonicalSrc, + canonicalSrc: this.linkCanonicalSrc, + title: this.linkTitle, + }) + .run(); + } + + this.endEditingLink(); + }, + + updateLinkToState() { + if (!this.tiptapEditor.isActive(Link.name)) return; + + const { href, title, canonicalSrc } = this.tiptapEditor.getAttributes(Link.name); + + this.linkTitle = title; + this.linkHref = href; + this.linkCanonicalSrc = canonicalSrc || href; + }, + + copyLinkHref() { + navigator.clipboard.writeText(this.linkCanonicalSrc); + }, + + removeLink() { + this.tiptapEditor.chain().focus().extendMarkRange(Link.name).unsetLink().run(); + }, + }, +}; +</script> +<template> + <bubble-menu + data-testid="link-bubble-menu" + class="gl-shadow gl-rounded-base gl-bg-white" + :editor="tiptapEditor" + plugin-key="bubbleMenuLink" + :should-show="() => shouldShow()" + :tippy-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + placement: 'bottom', + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + > + <editor-state-observer @transaction="updateLinkToState"> + <gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center"> + <gl-link + v-gl-tooltip + :href="linkHref" + :aria-label="linkCanonicalSrc" + :title="linkCanonicalSrc" + target="_blank" + class="gl-px-3 gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis" + > + {{ linkCanonicalSrc }} + </gl-link> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="copy-link-url" + :aria-label="__('Copy link URL')" + :title="__('Copy link URL')" + icon="copy-to-clipboard" + @click="copyLinkHref" + /> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="edit-link" + :aria-label="__('Edit link')" + :title="__('Edit link')" + icon="pencil" + @click="startEditingLink" + /> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="remove-link" + :aria-label="__('Remove link')" + :title="__('Remove link')" + icon="unlink" + @click="removeLink" + /> + </gl-button-group> + <gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedLink"> + <gl-form-group :label="__('URL')" label-for="link-href"> + <gl-form-input id="link-href" v-model="linkCanonicalSrc" data-testid="link-href" /> + </gl-form-group> + <gl-form-group :label="__('Title')" label-for="link-title"> + <gl-form-input id="link-title" v-model="linkTitle" data-testid="link-title" /> + </gl-form-group> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button class="gl-mr-3" data-testid="cancel-link" @click="cancelEditingLink"> + {{ __('Cancel') }} + </gl-button> + <gl-button variant="confirm" type="submit"> + {{ __('Apply') }} + </gl-button> + </div> + </gl-form> + </editor-state-observer> + </bubble-menu> +</template> diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/media.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media.vue new file mode 100644 index 00000000000..a36a860c440 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/bubble_menus/media.vue @@ -0,0 +1,288 @@ +<script> +import { + GlLink, + GlForm, + GlFormGroup, + GlFormInput, + GlLoadingIcon, + GlButton, + GlButtonGroup, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { BubbleMenu } from '@tiptap/vue-2'; +import { __ } from '~/locale'; +import Audio from '../../extensions/audio'; +import Image from '../../extensions/image'; +import Video from '../../extensions/video'; +import EditorStateObserver from '../editor_state_observer.vue'; +import { acceptedMimes } from '../../services/upload_helpers'; + +const MEDIA_TYPES = [Audio.name, Image.name, Video.name]; + +export default { + i18n: { + copySourceLabels: { + [Audio.name]: __('Copy audio URL'), + [Image.name]: __('Copy image URL'), + [Video.name]: __('Copy video URL'), + }, + editLabels: { + [Audio.name]: __('Edit audio description'), + [Image.name]: __('Edit image description'), + [Video.name]: __('Edit video description'), + }, + replaceLabels: { + [Audio.name]: __('Replace audio'), + [Image.name]: __('Replace image'), + [Video.name]: __('Replace video'), + }, + deleteLabels: { + [Audio.name]: __('Delete audio'), + [Image.name]: __('Delete image'), + [Video.name]: __('Delete video'), + }, + }, + components: { + BubbleMenu, + GlForm, + GlFormGroup, + GlFormInput, + GlLink, + GlLoadingIcon, + GlButton, + GlButtonGroup, + EditorStateObserver, + }, + directives: { + GlTooltip, + }, + inject: ['tiptapEditor', 'contentEditor'], + data() { + return { + mediaType: undefined, + mediaSrc: undefined, + mediaCanonicalSrc: undefined, + mediaAlt: undefined, + mediaTitle: undefined, + + isEditing: false, + isUpdating: false, + isUploading: false, + }; + }, + computed: { + copySourceLabel() { + return this.$options.i18n.copySourceLabels[this.mediaType]; + }, + editLabel() { + return this.$options.i18n.editLabels[this.mediaType]; + }, + replaceLabel() { + return this.$options.i18n.replaceLabels[this.mediaType]; + }, + deleteLabel() { + return this.$options.i18n.deleteLabels[this.mediaType]; + }, + showProgressIndicator() { + return this.isUploading || this.isUpdating; + }, + }, + methods: { + shouldShow() { + const shouldShow = MEDIA_TYPES.some((type) => this.tiptapEditor.isActive(type)); + + if (!shouldShow) this.isEditing = false; + + return shouldShow; + }, + + startEditingMedia() { + this.isEditing = true; + }, + + endEditingMedia() { + this.isEditing = false; + + this.updateMediaInfoToState(); + }, + + cancelEditingMedia() { + this.endEditingMedia(); + this.updateMediaInfoToState(); + }, + + async saveEditedMedia() { + this.isUpdating = true; + + this.mediaSrc = await this.contentEditor.resolveUrl(this.mediaCanonicalSrc); + + const position = this.tiptapEditor.state.selection.from; + + this.tiptapEditor + .chain() + .focus() + .updateAttributes(this.mediaType, { + src: this.mediaSrc, + alt: this.mediaAlt, + canonicalSrc: this.mediaCanonicalSrc, + title: this.mediaTitle, + }) + .run(); + + this.tiptapEditor.commands.setNodeSelection(position); + + this.endEditingMedia(); + + this.isUpdating = false; + }, + + async updateMediaInfoToState() { + this.mediaType = MEDIA_TYPES.find((type) => this.tiptapEditor.isActive(type)); + + if (!this.mediaType) return; + + this.isUpdating = true; + + const { src, title, alt, canonicalSrc, uploading } = this.tiptapEditor.getAttributes( + this.mediaType, + ); + + this.mediaTitle = title; + this.mediaAlt = alt; + this.mediaCanonicalSrc = canonicalSrc || src; + this.isUploading = uploading; + this.mediaSrc = await this.contentEditor.resolveUrl(this.mediaCanonicalSrc); + + this.isUpdating = false; + }, + + replaceMedia() { + this.$refs.fileSelector.click(); + }, + + onFileSelect(e) { + this.tiptapEditor + .chain() + .focus() + .deleteSelection() + .uploadAttachment({ + file: e.target.files[0], + }) + .run(); + + this.$refs.fileSelector.value = ''; + }, + + copyMediaSrc() { + navigator.clipboard.writeText(this.mediaCanonicalSrc); + }, + + deleteMedia() { + this.tiptapEditor.chain().focus().deleteSelection().run(); + }, + }, + + acceptedMimes, +}; +</script> +<template> + <bubble-menu + data-testid="media-bubble-menu" + class="gl-shadow gl-rounded-base gl-bg-white" + :editor="tiptapEditor" + plugin-key="bubbleMenuMedia" + :should-show="() => shouldShow()" + > + <editor-state-observer @transaction="updateMediaInfoToState"> + <gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center"> + <gl-loading-icon v-if="showProgressIndicator" class="gl-pl-4 gl-pr-3" /> + <input + ref="fileSelector" + type="file" + name="content_editor_image" + :accept="$options.acceptedMimes[mediaType]" + class="gl-display-none" + data-qa-selector="file_upload_field" + @change="onFileSelect" + /> + + <gl-link + v-if="!showProgressIndicator" + v-gl-tooltip + :href="mediaSrc" + :aria-label="mediaCanonicalSrc" + :title="mediaCanonicalSrc" + target="_blank" + class="gl-px-3 gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis" + > + {{ mediaCanonicalSrc }} + </gl-link> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="copy-media-src" + :aria-label="copySourceLabel" + :title="copySourceLabel" + icon="copy-to-clipboard" + @click="copyMediaSrc" + /> + <gl-button + v-if="!showProgressIndicator" + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="edit-media" + :aria-label="editLabel" + :title="editLabel" + icon="pencil" + @click="startEditingMedia" + /> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="replace-media" + :aria-label="replaceLabel" + :title="replaceLabel" + icon="upload" + @click="replaceMedia" + /> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="delete-media" + :aria-label="deleteLabel" + :title="deleteLabel" + icon="remove" + @click="deleteMedia" + /> + </gl-button-group> + <gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedMedia"> + <gl-form-group :label="__('URL')" label-for="media-src"> + <gl-form-input id="media-src" v-model="mediaCanonicalSrc" data-testid="media-src" /> + </gl-form-group> + <gl-form-group :label="__('Description (alt text)')" label-for="media-alt"> + <gl-form-input id="media-alt" v-model="mediaAlt" data-testid="media-alt" /> + </gl-form-group> + <gl-form-group :label="__('Title')" label-for="media-title"> + <gl-form-input id="media-title" v-model="mediaTitle" data-testid="media-title" /> + </gl-form-group> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button + class="gl-mr-3" + data-testid="cancel-editing-media" + @click="cancelEditingMedia" + >{{ __('Cancel') }}</gl-button + > + <gl-button variant="confirm" type="submit">{{ __('Apply') }}</gl-button> + </div> + </gl-form> + </editor-state-observer> + </bubble-menu> +</template> diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 5b3f4f4ddf2..74ae37b6d06 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -4,8 +4,10 @@ import { createContentEditor } from '../services/create_content_editor'; import ContentEditorAlert from './content_editor_alert.vue'; import ContentEditorProvider from './content_editor_provider.vue'; import EditorStateObserver from './editor_state_observer.vue'; -import FormattingBubbleMenu from './formatting_bubble_menu.vue'; -import CodeBlockBubbleMenu from './code_block_bubble_menu.vue'; +import FormattingBubbleMenu from './bubble_menus/formatting.vue'; +import CodeBlockBubbleMenu from './bubble_menus/code_block.vue'; +import LinkBubbleMenu from './bubble_menus/link.vue'; +import MediaBubbleMenu from './bubble_menus/media.vue'; import TopToolbar from './top_toolbar.vue'; import LoadingIndicator from './loading_indicator.vue'; @@ -18,6 +20,8 @@ export default { TopToolbar, FormattingBubbleMenu, CodeBlockBubbleMenu, + LinkBubbleMenu, + MediaBubbleMenu, EditorStateObserver, }, props: { @@ -92,6 +96,8 @@ export default { <div class="gl-relative"> <formatting-bubble-menu /> <code-block-bubble-menu /> + <link-bubble-menu /> + <media-bubble-menu /> <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> <loading-indicator /> </div> diff --git a/app/assets/javascripts/content_editor/components/divider.vue b/app/assets/javascripts/content_editor/components/divider.vue deleted file mode 100644 index b77bd7b7cf3..00000000000 --- a/app/assets/javascripts/content_editor/components/divider.vue +++ /dev/null @@ -1,3 +0,0 @@ -<template> - <span class="gl-mx-3 gl-border-r-solid gl-border-r-1 gl-border-gray-200"></span> -</template> diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue index 620324adb06..7bc953e0dc3 100644 --- a/app/assets/javascripts/content_editor/components/loading_indicator.vue +++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue @@ -34,7 +34,7 @@ export default { class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0" > <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div> - <gl-loading-icon size="md" /> + <gl-loading-icon size="lg" /> </div> </editor-state-observer> </template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue index cdb877152d4..c16dc34e36f 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue @@ -29,6 +29,11 @@ export default { required: false, default: '', }, + editorCommandParams: { + type: Object, + required: false, + default: null, + }, variant: { type: String, required: false, @@ -42,7 +47,7 @@ export default { size: { type: String, required: false, - default: 'small', + default: 'medium', }, }, data() { @@ -58,7 +63,7 @@ export default { const { contentType } = this; if (this.editorCommand) { - this.tiptapEditor.chain()[this.editorCommand]().focus().run(); + this.tiptapEditor.chain()[this.editorCommand](this.editorCommandParams).focus().run(); } this.$emit('execute', { contentType }); diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue index 89182b3a09f..19e150a4da9 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -1,6 +1,5 @@ <script> import trackUIControl from '../services/track_ui_control'; -import Divider from './divider.vue'; import ToolbarButton from './toolbar_button.vue'; import ToolbarImageButton from './toolbar_image_button.vue'; import ToolbarLinkButton from './toolbar_link_button.vue'; @@ -14,7 +13,6 @@ export default { ToolbarLinkButton, ToolbarTableButton, ToolbarImageButton, - Divider, }, methods: { trackToolbarControlExecution({ contentType, value }) { @@ -25,13 +23,13 @@ export default { </script> <template> <div - class="gl-display-flex gl-justify-content-end gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" + class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" > <toolbar-text-style-dropdown data-testid="text-styles" + class="gl-mr-3" @execute="trackToolbarControlExecution" /> - <divider /> <toolbar-button data-testid="bold" content-type="bold" @@ -69,7 +67,6 @@ export default { @execute="trackToolbarControlExecution" /> <toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" /> - <divider /> <toolbar-image-button ref="imageButton" data-testid="image" diff --git a/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue index e8829d00986..1390b9b2daf 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue @@ -1,9 +1,10 @@ <script> import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; import { __ } from '~/locale'; +import codeBlockLanguageLoader from '../../services/code_block_language_loader'; export default { - name: 'FrontMatter', + name: 'CodeBlock', components: { NodeViewWrapper, NodeViewContent, @@ -13,6 +14,16 @@ export default { type: Object, required: true, }, + updateAttributes: { + type: Function, + required: true, + }, + }, + async mounted() { + const lang = codeBlockLanguageLoader.findLanguageBySyntax(this.node.attrs.language); + await codeBlockLanguageLoader.loadLanguage(lang.syntax); + + this.updateAttributes({ language: this.node.attrs.language }); }, i18n: { frontmatter: __('frontmatter'), @@ -22,6 +33,7 @@ export default { <template> <node-view-wrapper class="content-editor-code-block gl-relative code highlight" as="pre"> <span + v-if="node.attrs.isFrontmatter" data-testid="frontmatter-label" class="gl-absolute gl-top-0 gl-right-3" contenteditable="false" diff --git a/app/assets/javascripts/content_editor/components/wrappers/media.vue b/app/assets/javascripts/content_editor/components/wrappers/media.vue deleted file mode 100644 index 37119bdd066..00000000000 --- a/app/assets/javascripts/content_editor/components/wrappers/media.vue +++ /dev/null @@ -1,51 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { NodeViewWrapper } from '@tiptap/vue-2'; - -const tagNameMap = { - image: 'img', - video: 'video', - audio: 'audio', -}; - -export default { - name: 'MediaWrapper', - components: { - NodeViewWrapper, - GlLoadingIcon, - }, - props: { - node: { - type: Object, - required: true, - }, - }, - computed: { - tagName() { - return tagNameMap[this.node.type.name] || 'img'; - }, - }, -}; -</script> -<template> - <node-view-wrapper class="gl-display-inline-block"> - <span class="gl-relative" :class="{ [`media-container ${tagName}-container`]: true }"> - <gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" /> - <component - :is="tagName" - data-testid="media" - :class="{ - 'gl-max-w-full gl-h-auto': tagName !== 'audio', - 'gl-opacity-5': node.attrs.uploading, - }" - :title="node.attrs.title || node.attrs.alt" - :alt="node.attrs.alt" - :src="node.attrs.src" - controls="true" - /> - <a v-if="tagName !== 'img'" :href="node.attrs.canonicalSrc || node.attrs.src" @click.prevent> - {{ node.attrs.title || node.attrs.alt }} - </a> - </span> - </node-view-wrapper> -</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue index 41c083111c5..209e4629830 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue @@ -124,7 +124,9 @@ export default { no-caret text-sr-only :text="$options.i18n.editTableActions" - :popper-opts="{ positionFixed: true }" + :popper-opts="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + positionFixed: true, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @hide="handleHide($event)" > <gl-dropdown-item @click="runCommand('addColumnBefore')"> diff --git a/app/assets/javascripts/content_editor/constants/code_block_languages.js b/app/assets/javascripts/content_editor/constants/code_block_languages.js new file mode 100644 index 00000000000..1a4dbe4fa22 --- /dev/null +++ b/app/assets/javascripts/content_editor/constants/code_block_languages.js @@ -0,0 +1,210 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +// List of languages referenced from https://github.com/wooorm/lowlight#data +const CODE_BLOCK_LANGUAGES = [ + { syntax: '1c', label: '1C:Enterprise' }, + { syntax: 'abnf', label: 'Augmented Backus-Naur Form' }, + { syntax: 'accesslog', label: 'Apache Access Log' }, + { syntax: 'actionscript', variants: 'as', label: 'ActionScript' }, + { syntax: 'ada', label: 'Ada' }, + { syntax: 'angelscript', variants: 'asc', label: 'AngelScript' }, + { syntax: 'apache', variants: 'apacheconf', label: 'Apache config' }, + { syntax: 'applescript', variants: 'osascript', label: 'AppleScript' }, + { syntax: 'arcade', label: 'ArcGIS Arcade' }, + { syntax: 'arduino', variants: 'ino', label: 'Arduino' }, + { syntax: 'armasm', variants: 'arm', label: 'ARM Assembly' }, + { syntax: 'asciidoc', variants: 'adoc', label: 'AsciiDoc' }, + { syntax: 'aspectj', label: 'AspectJ' }, + { syntax: 'autohotkey', variants: 'ahk', label: 'AutoHotkey' }, + { syntax: 'autoit', label: 'AutoIt' }, + { syntax: 'avrasm', label: 'AVR Assembly' }, + { syntax: 'awk', label: 'Awk' }, + { syntax: 'axapta', variants: 'x++', label: 'X++' }, + { syntax: 'bash', variants: 'sh', label: 'Bash' }, + { syntax: 'basic', label: 'BASIC' }, + { syntax: 'bnf', label: 'Backus-Naur Form' }, + { syntax: 'brainfuck', variants: 'bf', label: 'Brainfuck' }, + { syntax: 'c', variants: 'h', label: 'C' }, + { syntax: 'cal', label: 'C/AL' }, + { syntax: 'capnproto', variants: 'capnp', label: "Cap'n Proto" }, + { syntax: 'ceylon', label: 'Ceylon' }, + { syntax: 'clean', variants: 'icl, dcl', label: 'Clean' }, + { syntax: 'clojure', variants: 'clj, edn', label: 'Clojure' }, + { syntax: 'clojure-repl', label: 'Clojure REPL' }, + { syntax: 'cmake', variants: 'cmake.in', label: 'CMake' }, + { syntax: 'coffeescript', variants: 'coffee, cson, iced', label: 'CoffeeScript' }, + { syntax: 'coq', label: 'Coq' }, + { syntax: 'cos', variants: 'cls', label: 'Caché Object Script' }, + { syntax: 'cpp', variants: 'cc, c++, h++, hpp, hh, hxx, cxx', label: 'C++' }, + { syntax: 'crmsh', variants: 'crm, pcmk', label: 'crmsh' }, + { syntax: 'crystal', variants: 'cr', label: 'Crystal' }, + { syntax: 'csharp', variants: 'cs, c#', label: 'C#' }, + { syntax: 'csp', label: 'CSP' }, + { syntax: 'css', label: 'CSS' }, + { syntax: 'd', label: 'D' }, + { syntax: 'dart', label: 'Dart' }, + { syntax: 'delphi', variants: 'dpr, dfm, pas, pascal', label: 'Delphi' }, + { syntax: 'diff', variants: 'patch', label: 'Diff' }, + { syntax: 'django', variants: 'jinja', label: 'Django' }, + { syntax: 'dns', variants: 'bind, zone', label: 'DNS Zone' }, + { syntax: 'dockerfile', variants: 'docker', label: 'Dockerfile' }, + { syntax: 'dos', variants: 'bat, cmd', label: 'Batch file (DOS)' }, + { syntax: 'dsconfig', label: 'DSConfig' }, + { syntax: 'dts', label: 'Device Tree' }, + { syntax: 'dust', variants: 'dst', label: 'Dust' }, + { syntax: 'ebnf', label: 'Extended Backus-Naur Form' }, + { syntax: 'elixir', variants: 'ex, exs', label: 'Elixir' }, + { syntax: 'elm', label: 'Elm' }, + { syntax: 'erb', label: 'ERB' }, + { syntax: 'erlang', variants: 'erl', label: 'Erlang' }, + { syntax: 'erlang-repl', label: 'Erlang REPL' }, + { syntax: 'excel', variants: 'xlsx, xls', label: 'Excel formulae' }, + { syntax: 'fix', label: 'FIX' }, + { syntax: 'flix', label: 'Flix' }, + { syntax: 'fortran', variants: 'f90, f95', label: 'Fortran' }, + { syntax: 'fsharp', variants: 'fs, f#', label: 'F#' }, + { syntax: 'gams', variants: 'gms', label: 'GAMS' }, + { syntax: 'gauss', variants: 'gss', label: 'GAUSS' }, + { syntax: 'gcode', variants: 'nc', label: 'G-code (ISO 6983)' }, + { syntax: 'gherkin', variants: 'feature', label: 'Gherkin' }, + { syntax: 'glsl', label: 'GLSL' }, + { syntax: 'gml', label: 'GML' }, + { syntax: 'go', variants: 'golang', label: 'Go' }, + { syntax: 'golo', label: 'Golo' }, + { syntax: 'gradle', label: 'Gradle' }, + { syntax: 'graphql', variants: 'gql', label: 'GraphQL' }, + { syntax: 'groovy', label: 'Groovy' }, + { syntax: 'haml', label: 'HAML' }, + { + syntax: 'handlebars', + variants: 'hbs, html.hbs, html.handlebars, htmlbars', + label: 'Handlebars', + }, + { syntax: 'haskell', variants: 'hs', label: 'Haskell' }, + { syntax: 'haxe', variants: 'hx', label: 'Haxe' }, + { syntax: 'hsp', label: 'HSP' }, + { syntax: 'http', variants: 'https', label: 'HTTP' }, + { syntax: 'hy', variants: 'hylang', label: 'Hy' }, + { syntax: 'inform7', variants: 'i7', label: 'Inform 7' }, + { syntax: 'ini', variants: 'toml', label: 'TOML, also INI' }, + { syntax: 'irpf90', label: 'IRPF90' }, + { syntax: 'isbl', label: 'ISBL' }, + { syntax: 'java', variants: 'jsp', label: 'Java' }, + { syntax: 'javascript', variants: 'js, jsx, mjs, cjs', label: 'Javascript' }, + { syntax: 'jboss-cli', variants: 'wildfly-cli', label: 'JBoss CLI' }, + { syntax: 'json', label: 'JSON' }, + { syntax: 'julia', label: 'Julia' }, + { syntax: 'julia-repl', variants: 'jldoctest', label: 'Julia REPL' }, + { syntax: 'kotlin', variants: 'kt, kts', label: 'Kotlin' }, + { syntax: 'lasso', variants: 'ls, lassoscript', label: 'Lasso' }, + { syntax: 'latex', variants: 'tex', label: 'LaTeX' }, + { syntax: 'ldif', label: 'LDIF' }, + { syntax: 'leaf', label: 'Leaf' }, + { syntax: 'less', label: 'Less' }, + { syntax: 'lisp', label: 'Lisp' }, + { syntax: 'livecodeserver', label: 'LiveCode' }, + { syntax: 'livescript', variants: 'ls', label: 'LiveScript' }, + { syntax: 'llvm', label: 'LLVM IR' }, + { syntax: 'lsl', label: 'LSL (Linden Scripting Language)' }, + { syntax: 'lua', label: 'Lua' }, + { syntax: 'makefile', variants: 'mk, mak, make', label: 'Makefile' }, + { syntax: 'markdown', variants: 'md, mkdown, mkd', label: 'Markdown' }, + { syntax: 'mathematica', variants: 'mma, wl', label: 'Mathematica' }, + { syntax: 'matlab', label: 'Matlab' }, + { syntax: 'maxima', label: 'Maxima' }, + { syntax: 'mel', label: 'MEL' }, + { syntax: 'mercury', variants: 'm, moo', label: 'Mercury' }, + { syntax: 'mipsasm', variants: 'mips', label: 'MIPS Assembly' }, + { syntax: 'mizar', label: 'Mizar' }, + { syntax: 'mojolicious', label: 'Mojolicious' }, + { syntax: 'monkey', label: 'Monkey' }, + { syntax: 'moonscript', variants: 'moon', label: 'MoonScript' }, + { syntax: 'n1ql', label: 'N1QL' }, + { syntax: 'nestedtext', variants: 'nt', label: 'Nested Text' }, + { syntax: 'nginx', variants: 'nginxconf', label: 'Nginx config' }, + { syntax: 'nim', label: 'Nim' }, + { syntax: 'nix', variants: 'nixos', label: 'Nix' }, + { syntax: 'node-repl', label: 'Node REPL' }, + { syntax: 'nsis', label: 'NSIS' }, + { + syntax: 'objectivec', + variants: 'mm, objc, obj-c, obj-c++, objective-c++', + label: 'Objective-C', + }, + { syntax: 'ocaml', variants: 'ml', label: 'OCaml' }, + { syntax: 'openscad', variants: 'scad', label: 'OpenSCAD' }, + { syntax: 'oxygene', label: 'Oxygene' }, + { syntax: 'parser3', label: 'Parser3' }, + { syntax: 'perl', variants: 'pl, pm', label: 'Perl' }, + { syntax: 'pf', variants: 'pf.conf', label: 'Packet Filter config' }, + { syntax: 'pgsql', variants: 'postgres, postgresql', label: 'PostgreSQL' }, + { syntax: 'php', label: 'PHP' }, + { syntax: 'php-template', label: 'PHP template' }, + { syntax: 'plaintext', variants: 'text, txt', label: 'Plain text' }, + { syntax: 'pony', label: 'Pony' }, + { syntax: 'powershell', variants: 'pwsh, ps, ps1', label: 'PowerShell' }, + { syntax: 'processing', variants: 'pde', label: 'Processing' }, + { syntax: 'profile', label: 'Python profiler' }, + { syntax: 'prolog', label: 'Prolog' }, + { syntax: 'properties', label: '.properties' }, + { syntax: 'protobuf', label: 'Protocol Buffers' }, + { syntax: 'puppet', variants: 'pp', label: 'Puppet' }, + { syntax: 'purebasic', variants: 'pb, pbi', label: 'PureBASIC' }, + { syntax: 'python', variants: 'py, gyp, ipython', label: 'Python' }, + { syntax: 'python-repl', variants: 'pycon', label: 'Python REPL' }, + { syntax: 'q', variants: 'k, kdb', label: 'Q' }, + { syntax: 'qml', variants: 'qt', label: 'QML' }, + { syntax: 'r', label: 'R' }, + { syntax: 'reasonml', variants: 're', label: 'ReasonML' }, + { syntax: 'rib', label: 'RenderMan RIB' }, + { syntax: 'roboconf', variants: 'graph, instances', label: 'Roboconf' }, + { syntax: 'routeros', variants: 'mikrotik', label: 'Microtik RouterOS script' }, + { syntax: 'rsl', label: 'RenderMan RSL' }, + { syntax: 'ruby', variants: 'rb, gemspec, podspec, thor, irb', label: 'Ruby' }, + { syntax: 'ruleslanguage', label: 'Oracle Rules Language' }, + { syntax: 'rust', variants: 'rs', label: 'Rust' }, + { syntax: 'sas', label: 'SAS' }, + { syntax: 'scala', label: 'Scala' }, + { syntax: 'scheme', label: 'Scheme' }, + { syntax: 'scilab', variants: 'sci', label: 'Scilab' }, + { syntax: 'scss', label: 'SCSS' }, + { syntax: 'shell', variants: 'console, shellsession', label: 'Shell Session' }, + { syntax: 'smali', label: 'Smali' }, + { syntax: 'smalltalk', variants: 'st', label: 'Smalltalk' }, + { syntax: 'sml', variants: 'ml', label: 'SML (Standard ML)' }, + { syntax: 'sqf', label: 'SQF' }, + { syntax: 'sql', label: 'SQL' }, + { syntax: 'stan', variants: 'stanfuncs', label: 'Stan' }, + { syntax: 'stata', variants: 'do, ado', label: 'Stata' }, + { syntax: 'step21', variants: 'p21, step, stp', label: 'STEP Part 21' }, + { syntax: 'stylus', variants: 'styl', label: 'Stylus' }, + { syntax: 'subunit', label: 'SubUnit' }, + { syntax: 'swift', label: 'Swift' }, + { syntax: 'taggerscript', label: 'Tagger Script' }, + { syntax: 'tap', label: 'Test Anything Protocol' }, + { syntax: 'tcl', variants: 'tk', label: 'Tcl' }, + { syntax: 'thrift', label: 'Thrift' }, + { syntax: 'tp', label: 'TP' }, + { syntax: 'twig', variants: 'craftcms', label: 'Twig' }, + { syntax: 'typescript', variants: 'ts, tsx', label: 'TypeScript' }, + { syntax: 'vala', label: 'Vala' }, + { syntax: 'vbnet', variants: 'vb', label: 'Visual Basic .NET' }, + { syntax: 'vbscript', variants: 'vbs', label: 'VBScript' }, + { syntax: 'vbscript-html', label: 'VBScript in HTML' }, + { syntax: 'verilog', variants: 'v, sv, svh', label: 'Verilog' }, + { syntax: 'vhdl', label: 'VHDL' }, + { syntax: 'vim', label: 'Vim Script' }, + { syntax: 'wasm', label: 'WebAssembly' }, + { syntax: 'wren', label: 'Wren' }, + { syntax: 'x86asm', label: 'Intel x86 Assembly' }, + { syntax: 'xl', variants: 'tao', label: 'XL' }, + { + syntax: 'xml', + variants: 'html, xhtml, rss, atom, xjb, xsd, xsl, plist, wsf, svg', + label: 'HTML, XML', + }, + { syntax: 'xquery', variants: 'xpath, xq', label: 'XQuery' }, + { syntax: 'yaml', variants: 'yml', label: 'YAML' }, + { syntax: 'zephir', variants: 'zep', label: 'Zephir' }, +]; + +export default CODE_BLOCK_LANGUAGES; diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants/index.js index a39a243ec6b..a39a243ec6b 100644 --- a/app/assets/javascripts/content_editor/constants.js +++ b/app/assets/javascripts/content_editor/constants/index.js diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js index 5632bc28592..9b424ac8367 100644 --- a/app/assets/javascripts/content_editor/extensions/blockquote.js +++ b/app/assets/javascripts/content_editor/extensions/blockquote.js @@ -26,7 +26,7 @@ export default Blockquote.extend({ const multilineInputRegex = /^\s*>>>\s$/gm; return [ - ...this.parent?.(), + ...this.parent(), wrappingInputRule({ find: multilineInputRegex, type: this.type, diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 61f379fc0a2..cc4ba84a29d 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -1,6 +1,8 @@ import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; import { textblockTypeInputRule } from '@tiptap/core'; -import codeBlockLanguageLoader from '../services/code_block_language_loader'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import languageLoader from '../services/code_block_language_loader'; +import CodeBlockWrapper from '../components/wrappers/code_block.vue'; const extractLanguage = (element) => element.getAttribute('lang'); export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/; @@ -9,14 +11,6 @@ export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/; export default CodeBlockLowlight.extend({ isolating: true, exitOnArrowDown: false, - - addOptions() { - return { - ...this.parent?.(), - languageLoader: codeBlockLanguageLoader, - }; - }, - addAttributes() { return { language: { @@ -30,7 +24,6 @@ export default CodeBlockLowlight.extend({ }; }, addInputRules() { - const { languageLoader } = this.options; const getAttributes = (match) => languageLoader?.loadLanguageFromInputRule(match) || {}; return [ @@ -65,4 +58,8 @@ export default CodeBlockLowlight.extend({ ['code', {}, 0], ]; }, + + addNodeView() { + return new VueNodeViewRenderer(CodeBlockWrapper); + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js index d192b815092..f9dfeb92e9a 100644 --- a/app/assets/javascripts/content_editor/extensions/diagram.js +++ b/app/assets/javascripts/content_editor/extensions/diagram.js @@ -14,6 +14,9 @@ export default CodeBlockHighlight.extend({ return element.dataset.diagram; }, }, + isDiagram: { + default: true, + }, }; }, diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js index 9842027e192..2ec22158106 100644 --- a/app/assets/javascripts/content_editor/extensions/frontmatter.js +++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js @@ -1,10 +1,18 @@ -import { VueNodeViewRenderer } from '@tiptap/vue-2'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; -import FrontmatterWrapper from '../components/wrappers/frontmatter.vue'; import CodeBlockHighlight from './code_block_highlight'; export default CodeBlockHighlight.extend({ name: 'frontmatter', + + addAttributes() { + return { + ...this.parent?.(), + isFrontmatter: { + default: true, + }, + }; + }, + parseHTML() { return [ { @@ -24,9 +32,6 @@ export default CodeBlockHighlight.extend({ }, }; }, - addNodeView() { - return new VueNodeViewRenderer(FrontmatterWrapper); - }, addInputRules() { return []; diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 311db8151cb..25f976f524f 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -1,6 +1,4 @@ import { Image } from '@tiptap/extension-image'; -import { VueNodeViewRenderer } from '@tiptap/vue-2'; -import MediaWrapper from '../components/wrappers/media.vue'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; const resolveImageEl = (element) => @@ -77,7 +75,4 @@ export default Image.extend({ }, ]; }, - addNodeView() { - return VueNodeViewRenderer(MediaWrapper); - }, }); diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js index c349aa42a62..f87e4d8d1dd 100644 --- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js +++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js @@ -2,7 +2,7 @@ import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from 'prosemirror-state'; import { __ } from '~/locale'; import { VARIANT_DANGER } from '~/flash'; -import createMarkdownDeserializer from '../services/markdown_deserializer'; +import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer'; import { ALERT_EVENT, LOADING_CONTENT_EVENT, @@ -10,10 +10,14 @@ import { LOADING_ERROR_EVENT, EXTENSION_PRIORITY_HIGHEST, } from '../constants'; +import CodeBlockHighlight from './code_block_highlight'; +import Diagram from './diagram'; +import Frontmatter from './frontmatter'; const TEXT_FORMAT = 'text/plain'; const HTML_FORMAT = 'text/html'; const VS_CODE_FORMAT = 'vscode-editor-data'; +const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; export default Extension.create({ name: 'pasteMarkdown', @@ -75,6 +79,11 @@ export default Extension.create({ return false; } + // if a code block is active, paste as plain text + if (CODE_BLOCK_NODE_TYPES.some((type) => this.editor.isActive(type))) { + return false; + } + this.editor.commands.pasteMarkdown(content); return true; diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js index 2c5269377c5..ed343d8acf8 100644 --- a/app/assets/javascripts/content_editor/extensions/playable.js +++ b/app/assets/javascripts/content_editor/extensions/playable.js @@ -1,8 +1,6 @@ /* eslint-disable @gitlab/require-i18n-strings */ import { Node } from '@tiptap/core'; -import { VueNodeViewRenderer } from '@tiptap/vue-2'; -import MediaWrapper from '../components/wrappers/media.vue'; const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType); @@ -68,8 +66,4 @@ export default Node.create({ ['a', { href: node.attrs.src }, node.attrs.title || node.attrs.alt || ''], ]; }, - - addNodeView() { - return VueNodeViewRenderer(MediaWrapper); - }, }); diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js new file mode 100644 index 00000000000..94236e2e70e --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js @@ -0,0 +1,48 @@ +import { Extension } from '@tiptap/core'; +import Blockquote from './blockquote'; +import Bold from './bold'; +import BulletList from './bullet_list'; +import Code from './code'; +import CodeBlockHighlight from './code_block_highlight'; +import Heading from './heading'; +import HardBreak from './hard_break'; +import HorizontalRule from './horizontal_rule'; +import Image from './image'; +import Italic from './italic'; +import Link from './link'; +import ListItem from './list_item'; +import OrderedList from './ordered_list'; +import Paragraph from './paragraph'; + +export default Extension.create({ + addGlobalAttributes() { + return [ + { + types: [ + Bold.name, + Blockquote.name, + BulletList.name, + Code.name, + CodeBlockHighlight.name, + HardBreak.name, + Heading.name, + HorizontalRule.name, + Image.name, + Italic.name, + Link.name, + ListItem.name, + OrderedList.name, + Paragraph.name, + ], + attributes: { + sourceMarkdown: { + default: null, + }, + sourceMapKey: { + default: null, + }, + }, + }, + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/services/asset_resolver.js b/app/assets/javascripts/content_editor/services/asset_resolver.js new file mode 100644 index 00000000000..942457b9664 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/asset_resolver.js @@ -0,0 +1,13 @@ +import { memoize } from 'lodash'; + +export default ({ renderMarkdown }) => ({ + resolveUrl: memoize(async (canonicalSrc) => { + const html = await renderMarkdown(`[link](${canonicalSrc})`); + if (!html) return canonicalSrc; + + const parser = new DOMParser(); + const { body } = parser.parseFromString(html, 'text/html'); + + return body.querySelector('a').getAttribute('href'); + }), +}); diff --git a/app/assets/javascripts/content_editor/services/code_block_language_loader.js b/app/assets/javascripts/content_editor/services/code_block_language_loader.js index 081400cfd9a..1afaf4bfef6 100644 --- a/app/assets/javascripts/content_editor/services/code_block_language_loader.js +++ b/app/assets/javascripts/content_editor/services/code_block_language_loader.js @@ -1,215 +1,7 @@ import { lowlight } from 'lowlight/lib/core'; import { __, sprintf } from '~/locale'; - -/* eslint-disable @gitlab/require-i18n-strings */ -// List of languages referenced from https://github.com/wooorm/lowlight#data -const CODE_BLOCK_LANGUAGES = [ - { syntax: '1c', label: '1C:Enterprise' }, - { syntax: 'abnf', label: 'Augmented Backus-Naur Form' }, - { syntax: 'accesslog', label: 'Apache Access Log' }, - { syntax: 'actionscript', variants: 'as', label: 'ActionScript' }, - { syntax: 'ada', label: 'Ada' }, - { syntax: 'angelscript', variants: 'asc', label: 'AngelScript' }, - { syntax: 'apache', variants: 'apacheconf', label: 'Apache config' }, - { syntax: 'applescript', variants: 'osascript', label: 'AppleScript' }, - { syntax: 'arcade', label: 'ArcGIS Arcade' }, - { syntax: 'arduino', variants: 'ino', label: 'Arduino' }, - { syntax: 'armasm', variants: 'arm', label: 'ARM Assembly' }, - { syntax: 'asciidoc', variants: 'adoc', label: 'AsciiDoc' }, - { syntax: 'aspectj', label: 'AspectJ' }, - { syntax: 'autohotkey', variants: 'ahk', label: 'AutoHotkey' }, - { syntax: 'autoit', label: 'AutoIt' }, - { syntax: 'avrasm', label: 'AVR Assembly' }, - { syntax: 'awk', label: 'Awk' }, - { syntax: 'axapta', variants: 'x++', label: 'X++' }, - { syntax: 'bash', variants: 'sh', label: 'Bash' }, - { syntax: 'basic', label: 'BASIC' }, - { syntax: 'bnf', label: 'Backus-Naur Form' }, - { syntax: 'brainfuck', variants: 'bf', label: 'Brainfuck' }, - { syntax: 'c', variants: 'h', label: 'C' }, - { syntax: 'cal', label: 'C/AL' }, - { syntax: 'capnproto', variants: 'capnp', label: "Cap'n Proto" }, - { syntax: 'ceylon', label: 'Ceylon' }, - { syntax: 'clean', variants: 'icl, dcl', label: 'Clean' }, - { syntax: 'clojure', variants: 'clj, edn', label: 'Clojure' }, - { syntax: 'clojure-repl', label: 'Clojure REPL' }, - { syntax: 'cmake', variants: 'cmake.in', label: 'CMake' }, - { syntax: 'coffeescript', variants: 'coffee, cson, iced', label: 'CoffeeScript' }, - { syntax: 'coq', label: 'Coq' }, - { syntax: 'cos', variants: 'cls', label: 'Caché Object Script' }, - { syntax: 'cpp', variants: 'cc, c++, h++, hpp, hh, hxx, cxx', label: 'C++' }, - { syntax: 'crmsh', variants: 'crm, pcmk', label: 'crmsh' }, - { syntax: 'crystal', variants: 'cr', label: 'Crystal' }, - { syntax: 'csharp', variants: 'cs, c#', label: 'C#' }, - { syntax: 'csp', label: 'CSP' }, - { syntax: 'css', label: 'CSS' }, - { syntax: 'd', label: 'D' }, - { syntax: 'dart', label: 'Dart' }, - { syntax: 'delphi', variants: 'dpr, dfm, pas, pascal', label: 'Delphi' }, - { syntax: 'diff', variants: 'patch', label: 'Diff' }, - { syntax: 'django', variants: 'jinja', label: 'Django' }, - { syntax: 'dns', variants: 'bind, zone', label: 'DNS Zone' }, - { syntax: 'dockerfile', variants: 'docker', label: 'Dockerfile' }, - { syntax: 'dos', variants: 'bat, cmd', label: 'Batch file (DOS)' }, - { syntax: 'dsconfig', label: 'DSConfig' }, - { syntax: 'dts', label: 'Device Tree' }, - { syntax: 'dust', variants: 'dst', label: 'Dust' }, - { syntax: 'ebnf', label: 'Extended Backus-Naur Form' }, - { syntax: 'elixir', variants: 'ex, exs', label: 'Elixir' }, - { syntax: 'elm', label: 'Elm' }, - { syntax: 'erb', label: 'ERB' }, - { syntax: 'erlang', variants: 'erl', label: 'Erlang' }, - { syntax: 'erlang-repl', label: 'Erlang REPL' }, - { syntax: 'excel', variants: 'xlsx, xls', label: 'Excel formulae' }, - { syntax: 'fix', label: 'FIX' }, - { syntax: 'flix', label: 'Flix' }, - { syntax: 'fortran', variants: 'f90, f95', label: 'Fortran' }, - { syntax: 'fsharp', variants: 'fs, f#', label: 'F#' }, - { syntax: 'gams', variants: 'gms', label: 'GAMS' }, - { syntax: 'gauss', variants: 'gss', label: 'GAUSS' }, - { syntax: 'gcode', variants: 'nc', label: 'G-code (ISO 6983)' }, - { syntax: 'gherkin', variants: 'feature', label: 'Gherkin' }, - { syntax: 'glsl', label: 'GLSL' }, - { syntax: 'gml', label: 'GML' }, - { syntax: 'go', variants: 'golang', label: 'Go' }, - { syntax: 'golo', label: 'Golo' }, - { syntax: 'gradle', label: 'Gradle' }, - { syntax: 'graphql', variants: 'gql', label: 'GraphQL' }, - { syntax: 'groovy', label: 'Groovy' }, - { syntax: 'haml', label: 'HAML' }, - { - syntax: 'handlebars', - variants: 'hbs, html.hbs, html.handlebars, htmlbars', - label: 'Handlebars', - }, - { syntax: 'haskell', variants: 'hs', label: 'Haskell' }, - { syntax: 'haxe', variants: 'hx', label: 'Haxe' }, - { syntax: 'hsp', label: 'HSP' }, - { syntax: 'http', variants: 'https', label: 'HTTP' }, - { syntax: 'hy', variants: 'hylang', label: 'Hy' }, - { syntax: 'inform7', variants: 'i7', label: 'Inform 7' }, - { syntax: 'ini', variants: 'toml', label: 'TOML, also INI' }, - { syntax: 'irpf90', label: 'IRPF90' }, - { syntax: 'isbl', label: 'ISBL' }, - { syntax: 'java', variants: 'jsp', label: 'Java' }, - { syntax: 'javascript', variants: 'js, jsx, mjs, cjs', label: 'Javascript' }, - { syntax: 'jboss-cli', variants: 'wildfly-cli', label: 'JBoss CLI' }, - { syntax: 'json', label: 'JSON' }, - { syntax: 'julia', label: 'Julia' }, - { syntax: 'julia-repl', variants: 'jldoctest', label: 'Julia REPL' }, - { syntax: 'kotlin', variants: 'kt, kts', label: 'Kotlin' }, - { syntax: 'lasso', variants: 'ls, lassoscript', label: 'Lasso' }, - { syntax: 'latex', variants: 'tex', label: 'LaTeX' }, - { syntax: 'ldif', label: 'LDIF' }, - { syntax: 'leaf', label: 'Leaf' }, - { syntax: 'less', label: 'Less' }, - { syntax: 'lisp', label: 'Lisp' }, - { syntax: 'livecodeserver', label: 'LiveCode' }, - { syntax: 'livescript', variants: 'ls', label: 'LiveScript' }, - { syntax: 'llvm', label: 'LLVM IR' }, - { syntax: 'lsl', label: 'LSL (Linden Scripting Language)' }, - { syntax: 'lua', label: 'Lua' }, - { syntax: 'makefile', variants: 'mk, mak, make', label: 'Makefile' }, - { syntax: 'markdown', variants: 'md, mkdown, mkd', label: 'Markdown' }, - { syntax: 'mathematica', variants: 'mma, wl', label: 'Mathematica' }, - { syntax: 'matlab', label: 'Matlab' }, - { syntax: 'maxima', label: 'Maxima' }, - { syntax: 'mel', label: 'MEL' }, - { syntax: 'mercury', variants: 'm, moo', label: 'Mercury' }, - { syntax: 'mipsasm', variants: 'mips', label: 'MIPS Assembly' }, - { syntax: 'mizar', label: 'Mizar' }, - { syntax: 'mojolicious', label: 'Mojolicious' }, - { syntax: 'monkey', label: 'Monkey' }, - { syntax: 'moonscript', variants: 'moon', label: 'MoonScript' }, - { syntax: 'n1ql', label: 'N1QL' }, - { syntax: 'nestedtext', variants: 'nt', label: 'Nested Text' }, - { syntax: 'nginx', variants: 'nginxconf', label: 'Nginx config' }, - { syntax: 'nim', label: 'Nim' }, - { syntax: 'nix', variants: 'nixos', label: 'Nix' }, - { syntax: 'node-repl', label: 'Node REPL' }, - { syntax: 'nsis', label: 'NSIS' }, - { - syntax: 'objectivec', - variants: 'mm, objc, obj-c, obj-c++, objective-c++', - label: 'Objective-C', - }, - { syntax: 'ocaml', variants: 'ml', label: 'OCaml' }, - { syntax: 'openscad', variants: 'scad', label: 'OpenSCAD' }, - { syntax: 'oxygene', label: 'Oxygene' }, - { syntax: 'parser3', label: 'Parser3' }, - { syntax: 'perl', variants: 'pl, pm', label: 'Perl' }, - { syntax: 'pf', variants: 'pf.conf', label: 'Packet Filter config' }, - { syntax: 'pgsql', variants: 'postgres, postgresql', label: 'PostgreSQL' }, - { syntax: 'php', label: 'PHP' }, - { syntax: 'php-template', label: 'PHP template' }, - { syntax: 'plaintext', variants: 'text, txt', label: 'Plain text' }, - { syntax: 'pony', label: 'Pony' }, - { syntax: 'powershell', variants: 'pwsh, ps, ps1', label: 'PowerShell' }, - { syntax: 'processing', variants: 'pde', label: 'Processing' }, - { syntax: 'profile', label: 'Python profiler' }, - { syntax: 'prolog', label: 'Prolog' }, - { syntax: 'properties', label: '.properties' }, - { syntax: 'protobuf', label: 'Protocol Buffers' }, - { syntax: 'puppet', variants: 'pp', label: 'Puppet' }, - { syntax: 'purebasic', variants: 'pb, pbi', label: 'PureBASIC' }, - { syntax: 'python', variants: 'py, gyp, ipython', label: 'Python' }, - { syntax: 'python-repl', variants: 'pycon', label: 'Python REPL' }, - { syntax: 'q', variants: 'k, kdb', label: 'Q' }, - { syntax: 'qml', variants: 'qt', label: 'QML' }, - { syntax: 'r', label: 'R' }, - { syntax: 'reasonml', variants: 're', label: 'ReasonML' }, - { syntax: 'rib', label: 'RenderMan RIB' }, - { syntax: 'roboconf', variants: 'graph, instances', label: 'Roboconf' }, - { syntax: 'routeros', variants: 'mikrotik', label: 'Microtik RouterOS script' }, - { syntax: 'rsl', label: 'RenderMan RSL' }, - { syntax: 'ruby', variants: 'rb, gemspec, podspec, thor, irb', label: 'Ruby' }, - { syntax: 'ruleslanguage', label: 'Oracle Rules Language' }, - { syntax: 'rust', variants: 'rs', label: 'Rust' }, - { syntax: 'sas', label: 'SAS' }, - { syntax: 'scala', label: 'Scala' }, - { syntax: 'scheme', label: 'Scheme' }, - { syntax: 'scilab', variants: 'sci', label: 'Scilab' }, - { syntax: 'scss', label: 'SCSS' }, - { syntax: 'shell', variants: 'console, shellsession', label: 'Shell Session' }, - { syntax: 'smali', label: 'Smali' }, - { syntax: 'smalltalk', variants: 'st', label: 'Smalltalk' }, - { syntax: 'sml', variants: 'ml', label: 'SML (Standard ML)' }, - { syntax: 'sqf', label: 'SQF' }, - { syntax: 'sql', label: 'SQL' }, - { syntax: 'stan', variants: 'stanfuncs', label: 'Stan' }, - { syntax: 'stata', variants: 'do, ado', label: 'Stata' }, - { syntax: 'step21', variants: 'p21, step, stp', label: 'STEP Part 21' }, - { syntax: 'stylus', variants: 'styl', label: 'Stylus' }, - { syntax: 'subunit', label: 'SubUnit' }, - { syntax: 'swift', label: 'Swift' }, - { syntax: 'taggerscript', label: 'Tagger Script' }, - { syntax: 'tap', label: 'Test Anything Protocol' }, - { syntax: 'tcl', variants: 'tk', label: 'Tcl' }, - { syntax: 'thrift', label: 'Thrift' }, - { syntax: 'tp', label: 'TP' }, - { syntax: 'twig', variants: 'craftcms', label: 'Twig' }, - { syntax: 'typescript', variants: 'ts, tsx', label: 'TypeScript' }, - { syntax: 'vala', label: 'Vala' }, - { syntax: 'vbnet', variants: 'vb', label: 'Visual Basic .NET' }, - { syntax: 'vbscript', variants: 'vbs', label: 'VBScript' }, - { syntax: 'vbscript-html', label: 'VBScript in HTML' }, - { syntax: 'verilog', variants: 'v, sv, svh', label: 'Verilog' }, - { syntax: 'vhdl', label: 'VHDL' }, - { syntax: 'vim', label: 'Vim Script' }, - { syntax: 'wasm', label: 'WebAssembly' }, - { syntax: 'wren', label: 'Wren' }, - { syntax: 'x86asm', label: 'Intel x86 Assembly' }, - { syntax: 'xl', variants: 'tao', label: 'XL' }, - { - syntax: 'xml', - variants: 'html, xhtml, rss, atom, xjb, xsd, xsl, plist, wsf, svg', - label: 'HTML, XML', - }, - { syntax: 'xquery', variants: 'xpath, xq', label: 'XQuery' }, - { syntax: 'yaml', variants: 'yml', label: 'YAML' }, - { syntax: 'zephir', variants: 'zep', label: 'Zephir' }, -]; -/* eslint-enable @gitlab/require-i18n-strings */ +import CODE_BLOCK_LANGUAGES from '../constants/code_block_languages'; +import languageLoader from './highlight_js_language_loader'; const codeBlockLanguageLoader = { lowlight, @@ -245,38 +37,24 @@ const codeBlockLanguageLoader = { return this.lowlight.registered(language); }, - loadLanguagesFromDOM(domTree) { - const languages = []; - - domTree.querySelectorAll('pre').forEach((preElement) => { - languages.push(preElement.getAttribute('lang')); - }); - - return this.loadLanguages(languages); - }, - loadLanguageFromInputRule(match) { const { syntax } = this.findLanguageBySyntax(match[1]); - this.loadLanguages([syntax]); + this.loadLanguage(syntax); return { language: syntax }; }, - loadLanguages(languageList = []) { - const loaders = languageList - .filter((languageName) => !this.isLanguageLoaded(languageName)) - .map((languageName) => { - return import( - /* webpackChunkName: 'highlight.language.js' */ `highlight.js/lib/languages/${languageName}` - ) - .then(({ default: language }) => { - this.lowlight.registerLanguage(languageName, language); - }) - .catch(() => false); - }); + async loadLanguage(languageName) { + if (this.isLanguageLoaded(languageName)) return false; - return Promise.all(loaders); + try { + const { default: language } = await languageLoader[languageName](); + this.lowlight.registerLanguage(languageName, language); + return true; + } catch { + return false; + } }, }; diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index 56badf965ee..52dacb84153 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -3,12 +3,13 @@ import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } fro /* eslint-disable no-underscore-dangle */ export class ContentEditor { - constructor({ tiptapEditor, serializer, deserializer, eventHub, languageLoader }) { + constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub }) { this._tiptapEditor = tiptapEditor; this._serializer = serializer; this._deserializer = deserializer; this._eventHub = eventHub; - this._languageLoader = languageLoader; + this._assetResolver = assetResolver; + this._pristineDoc = null; } get tiptapEditor() { @@ -19,6 +20,10 @@ export class ContentEditor { return this._eventHub; } + get changed() { + return this._pristineDoc?.eq(this.tiptapEditor.state.doc); + } + get empty() { const doc = this.tiptapEditor?.state.doc; @@ -34,28 +39,30 @@ export class ContentEditor { this._eventHub.dispose(); } + deserialize(serializedContent) { + const { _tiptapEditor: editor, _deserializer: deserializer } = this; + + return deserializer.deserialize({ + schema: editor.schema, + content: serializedContent, + }); + } + + resolveUrl(canonicalSrc) { + return this._assetResolver.resolveUrl(canonicalSrc); + } + async setSerializedContent(serializedContent) { - const { - _tiptapEditor: editor, - _deserializer: deserializer, - _eventHub: eventHub, - _languageLoader: languageLoader, - } = this; + const { _tiptapEditor: editor, _eventHub: eventHub } = this; const { doc, tr } = editor.state; const selection = TextSelection.create(doc, 0, doc.content.size); try { eventHub.$emit(LOADING_CONTENT_EVENT); - const result = await deserializer.deserialize({ - schema: editor.schema, - content: serializedContent, - }); - - if (Object.keys(result).length !== 0) { - const { document, dom } = result; - - await languageLoader.loadLanguagesFromDOM(dom); + const { document } = await this.deserialize(serializedContent); + if (document) { + this._pristineDoc = document; tr.setSelection(selection) .replaceSelectionWith(document, false) .setMeta('preventUpdate', true); @@ -70,8 +77,9 @@ export class ContentEditor { } getSerializedContent() { - const { _tiptapEditor: editor, _serializer: serializer } = this; + const { _tiptapEditor: editor, _serializer: serializer, _pristineDoc: pristineDoc } = this; + const { doc } = editor.state; - return serializer.serialize({ schema: editor.schema, content: editor.getJSON() }); + return serializer.serialize({ doc, pristineDoc }); } } diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index af19a0ab0e4..15aac3d86e5 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -43,6 +43,7 @@ import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import PasteMarkdown from '../extensions/paste_markdown'; import Reference from '../extensions/reference'; +import Sourcemap from '../extensions/sourcemap'; import Strike from '../extensions/strike'; import Subscript from '../extensions/subscript'; import Superscript from '../extensions/superscript'; @@ -58,9 +59,10 @@ import Video from '../extensions/video'; import WordBreak from '../extensions/word_break'; import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; -import createMarkdownDeserializer from './markdown_deserializer'; +import createGlApiMarkdownDeserializer from './gl_api_markdown_deserializer'; +import createRemarkMarkdownDeserializer from './remark_markdown_deserializer'; +import createAssetResolver from './asset_resolver'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; -import languageLoader from './code_block_language_loader'; const createTiptapEditor = ({ extensions = [], ...options } = {}) => new Editor({ @@ -94,7 +96,7 @@ export const createContentEditor = ({ BulletList, Code, ColorChip, - CodeBlockHighlight.configure({ lowlight, languageLoader }), + CodeBlockHighlight.configure({ lowlight }), DescriptionItem, DescriptionList, Details, @@ -127,6 +129,7 @@ export const createContentEditor = ({ Paragraph, PasteMarkdown.configure({ renderMarkdown, eventHub }), Reference, + Sourcemap, Strike, Subscript, Superscript, @@ -146,7 +149,18 @@ export const createContentEditor = ({ const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts); const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions }); const serializer = createMarkdownSerializer({ serializerConfig }); - const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); + const deserializer = window.gon?.features?.preserveUnchangedMarkdown + ? createRemarkMarkdownDeserializer() + : createGlApiMarkdownDeserializer({ + render: renderMarkdown, + }); + const assetResolver = createAssetResolver({ renderMarkdown }); - return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer, languageLoader }); + return new ContentEditor({ + tiptapEditor, + serializer, + eventHub, + deserializer, + assetResolver, + }); }; diff --git a/app/assets/javascripts/content_editor/services/markdown_deserializer.js b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js index cd4863d8eac..dcd56e55268 100644 --- a/app/assets/javascripts/content_editor/services/markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js @@ -27,7 +27,7 @@ export default ({ render }) => { // append original source as a comment that nodes can access body.append(document.createComment(content)); - return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body), dom: body }; + return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) }; }, }; }; diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js new file mode 100644 index 00000000000..b6a3e0bc26a --- /dev/null +++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js @@ -0,0 +1,475 @@ +/** + * This module implements a function that converts a Hast Abstract + * Syntax Tree (AST) to a ProseMirror document. + * + * It is based on the prosemirror-markdown’s from_markdown module + * https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/from_markdown.js. + * + * It deviates significantly from the original because + * prosemirror-markdown supports converting an markdown-it AST instead of a + * HAST one. It also adds sourcemap attributes automatically to every + * ProseMirror node and mark created during the conversion process. + * + * We recommend becoming familiar with HAST and ProseMirror documents to + * facilitate the understanding of the behavior implemented in this module. + * + * Unist syntax tree documentation: https://github.com/syntax-tree/unist + * Hast tree documentation: https://github.com/syntax-tree/hast + * ProseMirror document documentation: https://prosemirror.net/docs/ref/#model.Document_Structure + * visit-parents documentation: https://github.com/syntax-tree/unist-util-visit-parents + */ + +import { Mark } from 'prosemirror-model'; +import { visitParents } from 'unist-util-visit-parents'; +import { toString } from 'hast-util-to-string'; +import { isFunction } from 'lodash'; + +/** + * Merges two ProseMirror text nodes if both text nodes + * have the same set of marks. + * + * @param {ProseMirror.Node} a first ProseMirror node + * @param {ProseMirror.Node} b second ProseMirror node + * @returns {model.Node} A new text node that results from combining + * the text of the two text node parameters or null. + */ +function maybeMerge(a, b) { + if (a && a.isText && b && b.isText && Mark.sameSet(a.marks, b.marks)) { + return a.withText(a.text + b.text); + } + + return null; +} + +/** + * Creates an object that contains sourcemap position information + * included in a Hast Abstract Syntax Tree. The Content + * Editor uses the sourcemap information to restore the + * original source of a node when the user doesn’t change it. + * + * Unist syntax tree documentation: https://github.com/syntax-tree/unist + * Hast node documentation: https://github.com/syntax-tree/hast + * + * @param {HastNode} hastNode A Hast node + * @param {String} source Markdown source file + * + * @returns It returns an object with the following attributes: + * + * - sourceMapKey: A string that uniquely identifies what is + * the position of the hast node in the Markdown source file. + * - sourceMarkdown: A node’s original Markdown source extrated + * from the Markdown source file. + */ +function createSourceMapAttributes(hastNode, source) { + const { position } = hastNode; + + return { + sourceMapKey: `${position.start.offset}:${position.end.offset}`, + sourceMarkdown: source.substring(position.start.offset, position.end.offset), + }; +} + +/** + * Compute ProseMirror node’s attributes from a Hast node. + * By default, this function includes sourcemap position + * information in the object returned. + * + * Other attributes are retrieved by invoking a getAttrs + * function provided by the ProseMirror node factory spec. + * + * @param {*} proseMirrorNodeSpec ProseMirror node spec object + * @param {HastNode} hastNode A hast node + * @param {Array<HastNode>} hastParents All the ancestors of the hastNode + * @param {String} source Markdown source file’s content + * + * @returns An object that contains a ProseMirror node’s attributes + */ +function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, source) { + const { getAttrs: specGetAttrs } = proseMirrorNodeSpec; + + return { + ...createSourceMapAttributes(hastNode, source), + ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, source) : {}), + }; +} + +/** + * Keeps track of the Hast -> ProseMirror conversion process. + * + * When the `openNode` method is invoked, it adds the node to a stack + * data structure. When the `closeNode` method is invoked, it removes the + * last element from the Stack, creates a ProseMirror node, and adds that + * ProseMirror node to the previous node in the Stack. + * + * For example, given a Hast tree with three levels of nodes: + * + * - blockquote + * - paragraph + * - text + * + * 3. text + * 2. paragraph + * 1. blockquote + * + * Calling `closeNode` will fold the text node into paragraph. A 2nd + * call to this method will fold "paragraph" into "blockquote". + * + * Mark state + * + * When the `openMark` method is invoked, this class adds the Mark to a `MarkSet` + * object. When a text node is added, it assigns all the opened marks to that text + * node and cleans the marks. It takes care of merging text nodes with the same + * set of marks as well. + */ +class HastToProseMirrorConverterState { + constructor() { + this.stack = []; + this.marks = Mark.none; + } + + /** + * Gets the first element of the node stack + */ + get top() { + return this.stack[this.stack.length - 1]; + } + + /** + * Detects if the node stack is empty + */ + get empty() { + return this.stack.length === 0; + } + + /** + * Creates a text node and adds it to + * the top node in the stack. + * + * It applies the marks stored temporarily + * by calling the `addMark` method. After + * the text node is added, it clears the mark + * set afterward. + * + * If the top block node has a text + * node with the same set of marks as the + * text node created, this method merges + * both text nodes + * + * @param {ProseMirror.Schema} schema ProseMirror schema + * @param {String} text Text + * @returns + */ + addText(schema, text) { + if (!text) return; + const nodes = this.top.content; + const last = nodes[nodes.length - 1]; + const node = schema.text(text, this.marks); + const merged = maybeMerge(last, node); + + if (last && merged) { + nodes[nodes.length - 1] = merged; + } else { + nodes.push(node); + } + + this.closeMarks(); + } + + /** + * Adds a mark to the set of marks stored temporarily + * until addText is called. + * @param {*} markType + * @param {*} attrs + */ + openMark(markType, attrs) { + this.marks = markType.create(attrs).addToSet(this.marks); + } + + /** + * Empties the temporary Mark set. + */ + closeMarks() { + this.marks = Mark.none; + } + + /** + * Adds a node to the stack data structure. + * + * @param {Schema.NodeType} type ProseMirror Schema for the node + * @param {HastNode} hastNode Hast node from which the ProseMirror node will be created + * @param {*} attrs Node’s attributes + * @param {*} factorySpec The factory spec used to create the node factory + */ + openNode(type, hastNode, attrs, factorySpec) { + this.stack.push({ type, attrs, content: [], hastNode, factorySpec }); + } + + /** + * Removes the top ProseMirror node from the + * conversion stack and adds the node to the + * previous element. + * @returns + */ + closeNode() { + const { type, attrs, content } = this.stack.pop(); + const node = type.createAndFill(attrs, content); + + if (!node) return null; + + if (this.marks.length) { + this.marks = Mark.none; + } + + if (!this.empty) { + this.top.content.push(node); + } + + return node; + } + + closeUntil(hastNode) { + while (hastNode !== this.top?.hastNode) { + this.closeNode(); + } + } +} + +/** + * Create ProseMirror node/mark factories based on one or more + * factory specifications. + * + * Note: Read `createProseMirrorDocFromMdastTree` documentation + * for instructions about how to define these specifications. + * + * @param {model.ProseMirrorSchema} schema A ProseMirror schema used to create the + * ProseMirror nodes and marks. + * @param {Object} proseMirrorFactorySpecs ProseMirror nodes factory specifications. + * @param {String} source Markdown source file’s content + * + * @returns An object that contains ProseMirror node factories + */ +const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) => { + const handlers = { + root: (state, hastNode) => state.openNode(schema.topNodeType, hastNode, {}), + text: (state, hastNode) => { + const { factorySpec } = state.top; + + if (/^\s+$/.test(hastNode.value)) { + return; + } + + if (factorySpec.wrapTextInParagraph === true) { + state.openNode(schema.nodeType('paragraph')); + state.addText(schema, hastNode.value); + state.closeNode(); + } else { + state.addText(schema, hastNode.value); + } + }, + }; + + for (const [hastNodeTagName, factorySpec] of Object.entries(proseMirrorFactorySpecs)) { + if (factorySpec.block) { + handlers[hastNodeTagName] = (state, hastNode, parent, ancestors) => { + const nodeType = schema.nodeType( + isFunction(factorySpec.block) + ? factorySpec.block(hastNode, parent, ancestors) + : factorySpec.block, + ); + + state.closeUntil(parent); + state.openNode( + nodeType, + hastNode, + getAttrs(factorySpec, hastNode, parent, source), + factorySpec, + ); + + /** + * If a getContent function is provided, we immediately close + * the node to delegate content processing to this function. + * */ + if (isFunction(factorySpec.getContent)) { + state.addText( + schema, + factorySpec.getContent({ hastNode, hastNodeText: toString(hastNode) }), + ); + state.closeNode(); + } + }; + } else if (factorySpec.inline) { + const nodeType = schema.nodeType(factorySpec.inline); + handlers[hastNodeTagName] = (state, hastNode, parent) => { + state.closeUntil(parent); + state.openNode( + nodeType, + hastNode, + getAttrs(factorySpec, hastNode, parent, source), + factorySpec, + ); + // Inline nodes do not have children therefore they are immediately closed + state.closeNode(); + }; + } else if (factorySpec.mark) { + const markType = schema.marks[factorySpec.mark]; + handlers[hastNodeTagName] = (state, hastNode, parent) => { + state.openMark(markType, getAttrs(factorySpec, hastNode, parent, source)); + + if (factorySpec.inlineContent) { + state.addText(schema, hastNode.value); + } + }; + } else { + throw new RangeError(`Unrecognized node factory spec ${JSON.stringify(factorySpec)}`); + } + } + + return handlers; +}; + +/** + * Converts a Hast AST to a ProseMirror document based on a series + * of specifications that describe how to map all the nodes of the former + * to ProseMirror nodes or marks. + * + * The specification object describes how to map a Hast node to a ProseMirror node or mark. + * The converter will trigger an error if it doesn’t find a specification + * for a Hast node while traversing the AST. + * + * The object should have the following shape: + * + * { + * [hastNode.tagName]: { + * [block|node|mark]: [ProseMirror.Node.name], + * ...configurationOptions + * } + * } + * + * Where each property in the object represents a HAST node with a given tag name, for example: + * + * { + * h1: {}, + * h2: {}, + * table: {}, + * strong: {}, + * // etc + * } + * + * You can specify the type of ProseMirror object adding one the following + * properties: + * + * 1. "block": A ProseMirror node that contains one or more children. + * 2. "inline": A ProseMirror node that doesn’t contain any children although + * it can have inline content like a code block or a reference. + * 3. "mark": A ProseMirror mark. + * + * The value of that property should be the name of the ProseMirror node or mark, i.e: + * + * { + * h1: { + * block: 'heading', + * }, + * h2: { + * block: 'heading', + * }, + * img: { + * node: 'image', + * }, + * strong: { + * mark: 'bold', + * } + * } + * + * You can compute a ProseMirror’s node or mark name based on the HAST node + * by passing a function instead of a String. The converter invokes the function + * and provides a HAST node object: + * + * { + * list: { + * block: (hastNode) => { + * let type = 'bulletList'; + + * if (hastNode.children.some(isTaskItem)) { + * type = 'taskList'; + * } else if (hastNode.ordered) { + * type = 'orderedList'; + * } + + * return type; + * } + * } + * } + * + * Configuration options + * ---------------------- + * + * You can customize the conversion process for every node or mark + * setting the following properties in the specification object: + * + * **getAttrs** + * + * Computes a ProseMirror node or mark attributes. The converter will invoke + * `getAttrs` with the following parameters: + * + * 1. hastNode: The hast node + * 2. hasParents: All the hast node’s ancestors up to the root node + * 3. source: Markdown source file’s content + * + * **wrapTextInParagraph** + * + * This property only applies to block nodes. If a block node contains text, + * it will wrap that text in a paragraph. This is useful for ProseMirror block + * nodes that don’t allow text directly such as list items and tables. + * + * **skipChildren** + * + * Skips a hast node’s children while traversing the tree. + * + * **getContent** + * + * Allows to pass a custom function that returns the content of a block node. The + * Content is limited to a single text node therefore the function should return + * a String value. + * + * Use this property along skipChildren to provide custom processing of child nodes + * for a block node. + * + * @param {model.Document_Schema} params.schema A ProseMirror schema that specifies the shape + * of the ProseMirror document. + * @param {Object} params.factorySpec A factory specification as described above + * @param {Hast} params.tree https://github.com/syntax-tree/hast + * @param {String} params.source Markdown source from which the MDast tree was generated + * + * @returns A ProseMirror document + */ +export const createProseMirrorDocFromMdastTree = ({ schema, factorySpecs, tree, source }) => { + const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, source); + const state = new HastToProseMirrorConverterState(); + + visitParents(tree, (hastNode, ancestors) => { + const parent = ancestors[ancestors.length - 1]; + const skipChildren = factorySpecs[hastNode.tagName]?.skipChildren; + + const handler = proseMirrorNodeFactories[hastNode.tagName || hastNode.type]; + + if (!handler) { + throw new Error( + `Hast node of type "${ + hastNode.tagName || hastNode.type + }" not supported by this converter. Please, provide an specification.`, + ); + } + + handler(state, hastNode, parent, ancestors); + + return skipChildren === true ? 'skip' : true; + }); + + let doc; + + do { + doc = state.closeNode(); + } while (!state.empty); + + return doc; +}; diff --git a/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js b/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js new file mode 100644 index 00000000000..a0ebbebed4e --- /dev/null +++ b/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js @@ -0,0 +1,248 @@ +/** + * This file is generated based on the contents of highlight.js/lib/languages to avoid + * utilizing dynamic expressions within `import()` which were the source of some + * confusion when attempting to produce deterministic webpack compilations across + * multiple build environments. + * + * This list of highlight-able languages will need to be updated as new options are + * introduced within the highlight.js dependency. + */ + +export default { + '1c': () => import(/* webpackChunkName: 'hl-1c' */ 'highlight.js/lib/languages/1c'), + abnf: () => import(/* webpackChunkName: 'hl-abnf' */ 'highlight.js/lib/languages/abnf'), + accesslog: () => + import(/* webpackChunkName: 'hl-accesslog' */ 'highlight.js/lib/languages/accesslog'), + actionscript: () => + import(/* webpackChunkName: 'hl-actionscript' */ 'highlight.js/lib/languages/actionscript'), + ada: () => import(/* webpackChunkName: 'hl-ada' */ 'highlight.js/lib/languages/ada'), + angelscript: () => + import(/* webpackChunkName: 'hl-angelscript' */ 'highlight.js/lib/languages/angelscript'), + apache: () => import(/* webpackChunkName: 'hl-apache' */ 'highlight.js/lib/languages/apache'), + applescript: () => + import(/* webpackChunkName: 'hl-applescript' */ 'highlight.js/lib/languages/applescript'), + arcade: () => import(/* webpackChunkName: 'hl-arcade' */ 'highlight.js/lib/languages/arcade'), + arduino: () => import(/* webpackChunkName: 'hl-arduino' */ 'highlight.js/lib/languages/arduino'), + armasm: () => import(/* webpackChunkName: 'hl-armasm' */ 'highlight.js/lib/languages/armasm'), + asciidoc: () => + import(/* webpackChunkName: 'hl-asciidoc' */ 'highlight.js/lib/languages/asciidoc'), + aspectj: () => import(/* webpackChunkName: 'hl-aspectj' */ 'highlight.js/lib/languages/aspectj'), + autohotkey: () => + import(/* webpackChunkName: 'hl-autohotkey' */ 'highlight.js/lib/languages/autohotkey'), + autoit: () => import(/* webpackChunkName: 'hl-autoit' */ 'highlight.js/lib/languages/autoit'), + avrasm: () => import(/* webpackChunkName: 'hl-avrasm' */ 'highlight.js/lib/languages/avrasm'), + awk: () => import(/* webpackChunkName: 'hl-awk' */ 'highlight.js/lib/languages/awk'), + axapta: () => import(/* webpackChunkName: 'hl-axapta' */ 'highlight.js/lib/languages/axapta'), + bash: () => import(/* webpackChunkName: 'hl-bash' */ 'highlight.js/lib/languages/bash'), + basic: () => import(/* webpackChunkName: 'hl-basic' */ 'highlight.js/lib/languages/basic'), + bnf: () => import(/* webpackChunkName: 'hl-bnf' */ 'highlight.js/lib/languages/bnf'), + brainfuck: () => + import(/* webpackChunkName: 'hl-brainfuck' */ 'highlight.js/lib/languages/brainfuck'), + c: () => import(/* webpackChunkName: 'hl-c' */ 'highlight.js/lib/languages/c'), + cal: () => import(/* webpackChunkName: 'hl-cal' */ 'highlight.js/lib/languages/cal'), + capnproto: () => + import(/* webpackChunkName: 'hl-capnproto' */ 'highlight.js/lib/languages/capnproto'), + ceylon: () => import(/* webpackChunkName: 'hl-ceylon' */ 'highlight.js/lib/languages/ceylon'), + clean: () => import(/* webpackChunkName: 'hl-clean' */ 'highlight.js/lib/languages/clean'), + 'clojure-repl': () => + import(/* webpackChunkName: 'hl-clojure-repl' */ 'highlight.js/lib/languages/clojure-repl'), + clojure: () => import(/* webpackChunkName: 'hl-clojure' */ 'highlight.js/lib/languages/clojure'), + cmake: () => import(/* webpackChunkName: 'hl-cmake' */ 'highlight.js/lib/languages/cmake'), + coffeescript: () => + import(/* webpackChunkName: 'hl-coffeescript' */ 'highlight.js/lib/languages/coffeescript'), + coq: () => import(/* webpackChunkName: 'hl-coq' */ 'highlight.js/lib/languages/coq'), + cos: () => import(/* webpackChunkName: 'hl-cos' */ 'highlight.js/lib/languages/cos'), + cpp: () => import(/* webpackChunkName: 'hl-cpp' */ 'highlight.js/lib/languages/cpp'), + crmsh: () => import(/* webpackChunkName: 'hl-crmsh' */ 'highlight.js/lib/languages/crmsh'), + crystal: () => import(/* webpackChunkName: 'hl-crystal' */ 'highlight.js/lib/languages/crystal'), + csharp: () => import(/* webpackChunkName: 'hl-csharp' */ 'highlight.js/lib/languages/csharp'), + csp: () => import(/* webpackChunkName: 'hl-csp' */ 'highlight.js/lib/languages/csp'), + css: () => import(/* webpackChunkName: 'hl-css' */ 'highlight.js/lib/languages/css'), + d: () => import(/* webpackChunkName: 'hl-d' */ 'highlight.js/lib/languages/d'), + dart: () => import(/* webpackChunkName: 'hl-dart' */ 'highlight.js/lib/languages/dart'), + delphi: () => import(/* webpackChunkName: 'hl-delphi' */ 'highlight.js/lib/languages/delphi'), + diff: () => import(/* webpackChunkName: 'hl-diff' */ 'highlight.js/lib/languages/diff'), + django: () => import(/* webpackChunkName: 'hl-django' */ 'highlight.js/lib/languages/django'), + dns: () => import(/* webpackChunkName: 'hl-dns' */ 'highlight.js/lib/languages/dns'), + dockerfile: () => + import(/* webpackChunkName: 'hl-dockerfile' */ 'highlight.js/lib/languages/dockerfile'), + dos: () => import(/* webpackChunkName: 'hl-dos' */ 'highlight.js/lib/languages/dos'), + dsconfig: () => + import(/* webpackChunkName: 'hl-dsconfig' */ 'highlight.js/lib/languages/dsconfig'), + dts: () => import(/* webpackChunkName: 'hl-dts' */ 'highlight.js/lib/languages/dts'), + dust: () => import(/* webpackChunkName: 'hl-dust' */ 'highlight.js/lib/languages/dust'), + ebnf: () => import(/* webpackChunkName: 'hl-ebnf' */ 'highlight.js/lib/languages/ebnf'), + elixir: () => import(/* webpackChunkName: 'hl-elixir' */ 'highlight.js/lib/languages/elixir'), + elm: () => import(/* webpackChunkName: 'hl-elm' */ 'highlight.js/lib/languages/elm'), + erb: () => import(/* webpackChunkName: 'hl-erb' */ 'highlight.js/lib/languages/erb'), + 'erlang-repl': () => + import(/* webpackChunkName: 'hl-erlang-repl' */ 'highlight.js/lib/languages/erlang-repl'), + erlang: () => import(/* webpackChunkName: 'hl-erlang' */ 'highlight.js/lib/languages/erlang'), + excel: () => import(/* webpackChunkName: 'hl-excel' */ 'highlight.js/lib/languages/excel'), + fix: () => import(/* webpackChunkName: 'hl-fix' */ 'highlight.js/lib/languages/fix'), + flix: () => import(/* webpackChunkName: 'hl-flix' */ 'highlight.js/lib/languages/flix'), + fortran: () => import(/* webpackChunkName: 'hl-fortran' */ 'highlight.js/lib/languages/fortran'), + fsharp: () => import(/* webpackChunkName: 'hl-fsharp' */ 'highlight.js/lib/languages/fsharp'), + gams: () => import(/* webpackChunkName: 'hl-gams' */ 'highlight.js/lib/languages/gams'), + gauss: () => import(/* webpackChunkName: 'hl-gauss' */ 'highlight.js/lib/languages/gauss'), + gcode: () => import(/* webpackChunkName: 'hl-gcode' */ 'highlight.js/lib/languages/gcode'), + gherkin: () => import(/* webpackChunkName: 'hl-gherkin' */ 'highlight.js/lib/languages/gherkin'), + glsl: () => import(/* webpackChunkName: 'hl-glsl' */ 'highlight.js/lib/languages/glsl'), + gml: () => import(/* webpackChunkName: 'hl-gml' */ 'highlight.js/lib/languages/gml'), + go: () => import(/* webpackChunkName: 'hl-go' */ 'highlight.js/lib/languages/go'), + golo: () => import(/* webpackChunkName: 'hl-golo' */ 'highlight.js/lib/languages/golo'), + gradle: () => import(/* webpackChunkName: 'hl-gradle' */ 'highlight.js/lib/languages/gradle'), + groovy: () => import(/* webpackChunkName: 'hl-groovy' */ 'highlight.js/lib/languages/groovy'), + haml: () => import(/* webpackChunkName: 'hl-haml' */ 'highlight.js/lib/languages/haml'), + handlebars: () => + import(/* webpackChunkName: 'hl-handlebars' */ 'highlight.js/lib/languages/handlebars'), + haskell: () => import(/* webpackChunkName: 'hl-haskell' */ 'highlight.js/lib/languages/haskell'), + haxe: () => import(/* webpackChunkName: 'hl-haxe' */ 'highlight.js/lib/languages/haxe'), + hsp: () => import(/* webpackChunkName: 'hl-hsp' */ 'highlight.js/lib/languages/hsp'), + http: () => import(/* webpackChunkName: 'hl-http' */ 'highlight.js/lib/languages/http'), + hy: () => import(/* webpackChunkName: 'hl-hy' */ 'highlight.js/lib/languages/hy'), + inform7: () => import(/* webpackChunkName: 'hl-inform7' */ 'highlight.js/lib/languages/inform7'), + ini: () => import(/* webpackChunkName: 'hl-ini' */ 'highlight.js/lib/languages/ini'), + irpf90: () => import(/* webpackChunkName: 'hl-irpf90' */ 'highlight.js/lib/languages/irpf90'), + isbl: () => import(/* webpackChunkName: 'hl-isbl' */ 'highlight.js/lib/languages/isbl'), + java: () => import(/* webpackChunkName: 'hl-java' */ 'highlight.js/lib/languages/java'), + javascript: () => + import(/* webpackChunkName: 'hl-javascript' */ 'highlight.js/lib/languages/javascript'), + 'jboss-cli': () => + import(/* webpackChunkName: 'hl-jboss-cli' */ 'highlight.js/lib/languages/jboss-cli'), + json: () => import(/* webpackChunkName: 'hl-json' */ 'highlight.js/lib/languages/json'), + 'julia-repl': () => + import(/* webpackChunkName: 'hl-julia-repl' */ 'highlight.js/lib/languages/julia-repl'), + julia: () => import(/* webpackChunkName: 'hl-julia' */ 'highlight.js/lib/languages/julia'), + kotlin: () => import(/* webpackChunkName: 'hl-kotlin' */ 'highlight.js/lib/languages/kotlin'), + lasso: () => import(/* webpackChunkName: 'hl-lasso' */ 'highlight.js/lib/languages/lasso'), + latex: () => import(/* webpackChunkName: 'hl-latex' */ 'highlight.js/lib/languages/latex'), + ldif: () => import(/* webpackChunkName: 'hl-ldif' */ 'highlight.js/lib/languages/ldif'), + leaf: () => import(/* webpackChunkName: 'hl-leaf' */ 'highlight.js/lib/languages/leaf'), + less: () => import(/* webpackChunkName: 'hl-less' */ 'highlight.js/lib/languages/less'), + lisp: () => import(/* webpackChunkName: 'hl-lisp' */ 'highlight.js/lib/languages/lisp'), + livecodeserver: () => + import(/* webpackChunkName: 'hl-livecodeserver' */ 'highlight.js/lib/languages/livecodeserver'), + livescript: () => + import(/* webpackChunkName: 'hl-livescript' */ 'highlight.js/lib/languages/livescript'), + llvm: () => import(/* webpackChunkName: 'hl-llvm' */ 'highlight.js/lib/languages/llvm'), + lsl: () => import(/* webpackChunkName: 'hl-lsl' */ 'highlight.js/lib/languages/lsl'), + lua: () => import(/* webpackChunkName: 'hl-lua' */ 'highlight.js/lib/languages/lua'), + makefile: () => + import(/* webpackChunkName: 'hl-makefile' */ 'highlight.js/lib/languages/makefile'), + markdown: () => + import(/* webpackChunkName: 'hl-markdown' */ 'highlight.js/lib/languages/markdown'), + mathematica: () => + import(/* webpackChunkName: 'hl-mathematica' */ 'highlight.js/lib/languages/mathematica'), + matlab: () => import(/* webpackChunkName: 'hl-matlab' */ 'highlight.js/lib/languages/matlab'), + maxima: () => import(/* webpackChunkName: 'hl-maxima' */ 'highlight.js/lib/languages/maxima'), + mel: () => import(/* webpackChunkName: 'hl-mel' */ 'highlight.js/lib/languages/mel'), + mercury: () => import(/* webpackChunkName: 'hl-mercury' */ 'highlight.js/lib/languages/mercury'), + mipsasm: () => import(/* webpackChunkName: 'hl-mipsasm' */ 'highlight.js/lib/languages/mipsasm'), + mizar: () => import(/* webpackChunkName: 'hl-mizar' */ 'highlight.js/lib/languages/mizar'), + mojolicious: () => + import(/* webpackChunkName: 'hl-mojolicious' */ 'highlight.js/lib/languages/mojolicious'), + monkey: () => import(/* webpackChunkName: 'hl-monkey' */ 'highlight.js/lib/languages/monkey'), + moonscript: () => + import(/* webpackChunkName: 'hl-moonscript' */ 'highlight.js/lib/languages/moonscript'), + n1ql: () => import(/* webpackChunkName: 'hl-n1ql' */ 'highlight.js/lib/languages/n1ql'), + nestedtext: () => + import(/* webpackChunkName: 'hl-nestedtext' */ 'highlight.js/lib/languages/nestedtext'), + nginx: () => import(/* webpackChunkName: 'hl-nginx' */ 'highlight.js/lib/languages/nginx'), + nim: () => import(/* webpackChunkName: 'hl-nim' */ 'highlight.js/lib/languages/nim'), + nix: () => import(/* webpackChunkName: 'hl-nix' */ 'highlight.js/lib/languages/nix'), + 'node-repl': () => + import(/* webpackChunkName: 'hl-node-repl' */ 'highlight.js/lib/languages/node-repl'), + nsis: () => import(/* webpackChunkName: 'hl-nsis' */ 'highlight.js/lib/languages/nsis'), + objectivec: () => + import(/* webpackChunkName: 'hl-objectivec' */ 'highlight.js/lib/languages/objectivec'), + ocaml: () => import(/* webpackChunkName: 'hl-ocaml' */ 'highlight.js/lib/languages/ocaml'), + openscad: () => + import(/* webpackChunkName: 'hl-openscad' */ 'highlight.js/lib/languages/openscad'), + oxygene: () => import(/* webpackChunkName: 'hl-oxygene' */ 'highlight.js/lib/languages/oxygene'), + parser3: () => import(/* webpackChunkName: 'hl-parser3' */ 'highlight.js/lib/languages/parser3'), + perl: () => import(/* webpackChunkName: 'hl-perl' */ 'highlight.js/lib/languages/perl'), + pf: () => import(/* webpackChunkName: 'hl-pf' */ 'highlight.js/lib/languages/pf'), + pgsql: () => import(/* webpackChunkName: 'hl-pgsql' */ 'highlight.js/lib/languages/pgsql'), + 'php-template': () => + import(/* webpackChunkName: 'hl-php-template' */ 'highlight.js/lib/languages/php-template'), + php: () => import(/* webpackChunkName: 'hl-php' */ 'highlight.js/lib/languages/php'), + plaintext: () => + import(/* webpackChunkName: 'hl-plaintext' */ 'highlight.js/lib/languages/plaintext'), + pony: () => import(/* webpackChunkName: 'hl-pony' */ 'highlight.js/lib/languages/pony'), + powershell: () => + import(/* webpackChunkName: 'hl-powershell' */ 'highlight.js/lib/languages/powershell'), + processing: () => + import(/* webpackChunkName: 'hl-processing' */ 'highlight.js/lib/languages/processing'), + profile: () => import(/* webpackChunkName: 'hl-profile' */ 'highlight.js/lib/languages/profile'), + prolog: () => import(/* webpackChunkName: 'hl-prolog' */ 'highlight.js/lib/languages/prolog'), + properties: () => + import(/* webpackChunkName: 'hl-properties' */ 'highlight.js/lib/languages/properties'), + protobuf: () => + import(/* webpackChunkName: 'hl-protobuf' */ 'highlight.js/lib/languages/protobuf'), + puppet: () => import(/* webpackChunkName: 'hl-puppet' */ 'highlight.js/lib/languages/puppet'), + purebasic: () => + import(/* webpackChunkName: 'hl-purebasic' */ 'highlight.js/lib/languages/purebasic'), + 'python-repl': () => + import(/* webpackChunkName: 'hl-python-repl' */ 'highlight.js/lib/languages/python-repl'), + python: () => import(/* webpackChunkName: 'hl-python' */ 'highlight.js/lib/languages/python'), + q: () => import(/* webpackChunkName: 'hl-q' */ 'highlight.js/lib/languages/q'), + qml: () => import(/* webpackChunkName: 'hl-qml' */ 'highlight.js/lib/languages/qml'), + r: () => import(/* webpackChunkName: 'hl-r' */ 'highlight.js/lib/languages/r'), + reasonml: () => + import(/* webpackChunkName: 'hl-reasonml' */ 'highlight.js/lib/languages/reasonml'), + rib: () => import(/* webpackChunkName: 'hl-rib' */ 'highlight.js/lib/languages/rib'), + roboconf: () => + import(/* webpackChunkName: 'hl-roboconf' */ 'highlight.js/lib/languages/roboconf'), + routeros: () => + import(/* webpackChunkName: 'hl-routeros' */ 'highlight.js/lib/languages/routeros'), + rsl: () => import(/* webpackChunkName: 'hl-rsl' */ 'highlight.js/lib/languages/rsl'), + ruby: () => import(/* webpackChunkName: 'hl-ruby' */ 'highlight.js/lib/languages/ruby'), + ruleslanguage: () => + import(/* webpackChunkName: 'hl-ruleslanguage' */ 'highlight.js/lib/languages/ruleslanguage'), + rust: () => import(/* webpackChunkName: 'hl-rust' */ 'highlight.js/lib/languages/rust'), + sas: () => import(/* webpackChunkName: 'hl-sas' */ 'highlight.js/lib/languages/sas'), + scala: () => import(/* webpackChunkName: 'hl-scala' */ 'highlight.js/lib/languages/scala'), + scheme: () => import(/* webpackChunkName: 'hl-scheme' */ 'highlight.js/lib/languages/scheme'), + scilab: () => import(/* webpackChunkName: 'hl-scilab' */ 'highlight.js/lib/languages/scilab'), + scss: () => import(/* webpackChunkName: 'hl-scss' */ 'highlight.js/lib/languages/scss'), + shell: () => import(/* webpackChunkName: 'hl-shell' */ 'highlight.js/lib/languages/shell'), + smali: () => import(/* webpackChunkName: 'hl-smali' */ 'highlight.js/lib/languages/smali'), + smalltalk: () => + import(/* webpackChunkName: 'hl-smalltalk' */ 'highlight.js/lib/languages/smalltalk'), + sml: () => import(/* webpackChunkName: 'hl-sml' */ 'highlight.js/lib/languages/sml'), + sqf: () => import(/* webpackChunkName: 'hl-sqf' */ 'highlight.js/lib/languages/sqf'), + sql: () => import(/* webpackChunkName: 'hl-sql' */ 'highlight.js/lib/languages/sql'), + stan: () => import(/* webpackChunkName: 'hl-stan' */ 'highlight.js/lib/languages/stan'), + stata: () => import(/* webpackChunkName: 'hl-stata' */ 'highlight.js/lib/languages/stata'), + step21: () => import(/* webpackChunkName: 'hl-step21' */ 'highlight.js/lib/languages/step21'), + stylus: () => import(/* webpackChunkName: 'hl-stylus' */ 'highlight.js/lib/languages/stylus'), + subunit: () => import(/* webpackChunkName: 'hl-subunit' */ 'highlight.js/lib/languages/subunit'), + swift: () => import(/* webpackChunkName: 'hl-swift' */ 'highlight.js/lib/languages/swift'), + taggerscript: () => + import(/* webpackChunkName: 'hl-taggerscript' */ 'highlight.js/lib/languages/taggerscript'), + tap: () => import(/* webpackChunkName: 'hl-tap' */ 'highlight.js/lib/languages/tap'), + tcl: () => import(/* webpackChunkName: 'hl-tcl' */ 'highlight.js/lib/languages/tcl'), + thrift: () => import(/* webpackChunkName: 'hl-thrift' */ 'highlight.js/lib/languages/thrift'), + tp: () => import(/* webpackChunkName: 'hl-tp' */ 'highlight.js/lib/languages/tp'), + twig: () => import(/* webpackChunkName: 'hl-twig' */ 'highlight.js/lib/languages/twig'), + typescript: () => + import(/* webpackChunkName: 'hl-typescript' */ 'highlight.js/lib/languages/typescript'), + vala: () => import(/* webpackChunkName: 'hl-vala' */ 'highlight.js/lib/languages/vala'), + vbnet: () => import(/* webpackChunkName: 'hl-vbnet' */ 'highlight.js/lib/languages/vbnet'), + 'vbscript-html': () => + import(/* webpackChunkName: 'hl-vbscript-html' */ 'highlight.js/lib/languages/vbscript-html'), + vbscript: () => + import(/* webpackChunkName: 'hl-vbscript' */ 'highlight.js/lib/languages/vbscript'), + verilog: () => import(/* webpackChunkName: 'hl-verilog' */ 'highlight.js/lib/languages/verilog'), + vhdl: () => import(/* webpackChunkName: 'hl-vhdl' */ 'highlight.js/lib/languages/vhdl'), + vim: () => import(/* webpackChunkName: 'hl-vim' */ 'highlight.js/lib/languages/vim'), + wasm: () => import(/* webpackChunkName: 'hl-wasm' */ 'highlight.js/lib/languages/wasm'), + wren: () => import(/* webpackChunkName: 'hl-wren' */ 'highlight.js/lib/languages/wren'), + x86asm: () => import(/* webpackChunkName: 'hl-x86asm' */ 'highlight.js/lib/languages/x86asm'), + xl: () => import(/* webpackChunkName: 'hl-xl' */ 'highlight.js/lib/languages/xl'), + xml: () => import(/* webpackChunkName: 'hl-xml' */ 'highlight.js/lib/languages/xml'), + xquery: () => import(/* webpackChunkName: 'hl-xquery' */ 'highlight.js/lib/languages/xquery'), + yaml: () => import(/* webpackChunkName: 'hl-yaml' */ 'highlight.js/lib/languages/yaml'), + zephir: () => import(/* webpackChunkName: 'hl-zephir' */ 'highlight.js/lib/languages/zephir'), +}; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index c2be7bc9195..d665f24bba1 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -48,7 +48,6 @@ import Text from '../extensions/text'; import Video from '../extensions/video'; import WordBreak from '../extensions/word_break'; import { - isPlainURL, renderCodeBlock, renderHardBreak, renderTable, @@ -61,36 +60,30 @@ import { renderPlayable, renderHTMLNode, renderContent, + preserveUnchanged, + bold, + italic, + link, + code, } from './serialization_helpers'; const defaultSerializerConfig = { marks: { - [Bold.name]: defaultMarkdownSerializer.marks.strong, - [Italic.name]: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true }, - [Code.name]: defaultMarkdownSerializer.marks.code, + [Bold.name]: bold, + [Italic.name]: italic, + [Code.name]: code, [Subscript.name]: { open: '<sub>', close: '</sub>', mixable: true }, [Superscript.name]: { open: '<sup>', close: '</sup>', mixable: true }, [InlineDiff.name]: { mixable: true, - open(state, mark) { + open(_, mark) { return mark.attrs.type === 'addition' ? '{+' : '{-'; }, - close(state, mark) { + close(_, mark) { return mark.attrs.type === 'addition' ? '+}' : '-}'; }, }, - [Link.name]: { - open(state, mark, parent, index) { - return isPlainURL(mark, parent, index, 1) ? '<' : '['; - }, - close(state, mark, parent, index) { - const href = mark.attrs.canonicalSrc || mark.attrs.href; - - return isPlainURL(mark, parent, index, -1) - ? '>' - : `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`; - }, - }, + [Link.name]: link, [MathInline.name]: { open: (...args) => `$${defaultMarkdownSerializer.marks.code.open(...args)}`, close: (...args) => `${defaultMarkdownSerializer.marks.code.close(...args)}$`, @@ -119,7 +112,7 @@ const defaultSerializerConfig = { nodes: { [Audio.name]: renderPlayable, - [Blockquote.name]: (state, node) => { + [Blockquote.name]: preserveUnchanged((state, node) => { if (node.attrs.multiline) { state.write('>>>'); state.ensureNewLine(); @@ -130,9 +123,9 @@ const defaultSerializerConfig = { } else { state.wrapBlock('> ', null, node, () => state.renderContent(node)); } - }, - [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list, - [CodeBlockHighlight.name]: renderCodeBlock, + }), + [BulletList.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.bullet_list), + [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), [Diagram.name]: renderCodeBlock, [Division.name]: (state, node) => { if (node.attrs.className?.includes('js-markdown-code')) { @@ -189,13 +182,13 @@ const defaultSerializerConfig = { }, [Figure.name]: renderHTMLNode('figure'), [FigureCaption.name]: renderHTMLNode('figcaption'), - [HardBreak.name]: renderHardBreak, - [Heading.name]: defaultMarkdownSerializer.nodes.heading, - [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule, - [Image.name]: renderImage, - [ListItem.name]: defaultMarkdownSerializer.nodes.list_item, - [OrderedList.name]: renderOrderedList, - [Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph, + [HardBreak.name]: preserveUnchanged(renderHardBreak), + [Heading.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.heading), + [HorizontalRule.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.horizontal_rule), + [Image.name]: preserveUnchanged(renderImage), + [ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), + [OrderedList.name]: preserveUnchanged(renderOrderedList), + [Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), [Reference.name]: (state, node) => { state.write(node.attrs.originalText || node.attrs.text); }, @@ -221,29 +214,60 @@ const defaultSerializerConfig = { }, }; +const createChangeTracker = (doc, pristineDoc) => { + const changeTracker = new WeakMap(); + const pristineSourceMarkdownMap = new Map(); + + if (doc && pristineDoc) { + pristineDoc.descendants((node) => { + if (node.attrs.sourceMapKey) { + pristineSourceMarkdownMap.set(`${node.attrs.sourceMapKey}${node.type.name}`, node); + } + }); + doc.descendants((node) => { + const pristineNode = pristineSourceMarkdownMap.get( + `${node.attrs.sourceMapKey}${node.type.name}`, + ); + + if (pristineNode) { + changeTracker.set(node, node.eq(pristineNode)); + } + }); + } + + return changeTracker; +}; + /** - * A markdown serializer converts arbitrary Markdown content - * into a ProseMirror document and viceversa. To convert Markdown - * into a ProseMirror document, the Markdown should be rendered. + * Converts a ProseMirror document to Markdown. See the + * following documentation to learn how to implement + * custom node and mark serializer functions. + * + * https://github.com/prosemirror/prosemirror-markdown * - * The client should provide a render function to allow flexibility - * on the desired rendering approach. + * @param {Object} params.nodes ProseMirror node serializer functions + * @param {Object} params.marks ProseMirror marks serializer config * - * @param {Function} params.render Render function - * that parses the Markdown and converts it into HTML. * @returns a markdown serializer */ export default ({ serializerConfig = {} } = {}) => ({ /** - * Converts a ProseMirror JSONDocument based - * on a ProseMirror schema into Markdown - * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines - * the types of content supported in the document - * @param {String} params.content A ProseMirror JSONDocument - * @returns A Markdown string + * Serializes a ProseMirror document as Markdown. If a node contains + * sourcemap metadata, the serializer is capable of restoring the + * Markdown from which the node was generated using a Markdown + * deserializer. + * + * See the Sourcemap metadata extension and the remark_markdown_deserializer + * service for more information. + * + * @param {ProseMirror.Node} params.doc ProseMirror document to convert into Markdown + * @param {ProseMirror.Node} params.pristineDoc Pristine version of the document that + * should be converted into Markdown. This is used to detect which nodes in the document + * changed. + * @returns A String that represents the serialized document as Markdown */ - serialize: ({ schema, content }) => { - const proseMirrorDocument = schema.nodeFromJSON(content); + serialize: ({ doc, pristineDoc }) => { + const changeTracker = createChangeTracker(doc, pristineDoc); const serializer = new ProseMirrorMarkdownSerializer( { ...defaultSerializerConfig.nodes, @@ -255,8 +279,9 @@ export default ({ serializerConfig = {} } = {}) => ({ }, ); - return serializer.serialize(proseMirrorDocument, { + return serializer.serialize(doc, { tightLists: true, + changeTracker, }); }, }); diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js index 4285e04bbab..fe1b32c5b0a 100644 --- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js +++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js @@ -30,7 +30,7 @@ export const getMarkdownSource = (element) => { for (let i = range.start.row; i <= range.end.row; i += 1) { if (i === range.start.row) { - elSource += source[i]?.substring(range.start.col); + elSource += source[i].substring(range.start.col); } else if (i === range.end.row) { elSource += `\n${source[i]?.substring(0, range.start.col)}`; } else { diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js new file mode 100644 index 00000000000..770de1df0d0 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js @@ -0,0 +1,87 @@ +import { isString } from 'lodash'; +import { render } from '~/lib/gfm'; +import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter'; + +const factorySpecs = { + blockquote: { block: 'blockquote' }, + p: { block: 'paragraph' }, + li: { block: 'listItem', wrapTextInParagraph: true }, + ul: { block: 'bulletList' }, + ol: { block: 'orderedList' }, + h1: { + block: 'heading', + getAttrs: () => ({ level: 1 }), + }, + h2: { + block: 'heading', + getAttrs: () => ({ level: 2 }), + }, + h3: { + block: 'heading', + getAttrs: () => ({ level: 3 }), + }, + h4: { + block: 'heading', + getAttrs: () => ({ level: 4 }), + }, + h5: { + block: 'heading', + getAttrs: () => ({ level: 5 }), + }, + h6: { + block: 'heading', + getAttrs: () => ({ level: 6 }), + }, + pre: { + block: 'codeBlock', + skipChildren: true, + getContent: ({ hastNodeText }) => hastNodeText.replace(/\n$/, ''), + getAttrs: (hastNode) => { + const languageClass = hastNode.children[0]?.properties.className?.[0]; + const language = isString(languageClass) ? languageClass.replace('language-', '') : null; + + return { language }; + }, + }, + hr: { inline: 'horizontalRule' }, + img: { + inline: 'image', + getAttrs: (hastNode) => ({ + src: hastNode.properties.src, + title: hastNode.properties.title, + alt: hastNode.properties.alt, + }), + }, + br: { inline: 'hardBreak' }, + code: { mark: 'code' }, + em: { mark: 'italic' }, + i: { mark: 'italic' }, + strong: { mark: 'bold' }, + b: { mark: 'bold' }, + a: { + mark: 'link', + getAttrs: (hastNode) => ({ + href: hastNode.properties.href, + title: hastNode.properties.title, + }), + }, +}; + +export default () => { + return { + deserialize: async ({ schema, content: markdown }) => { + const document = await render({ + markdown, + renderer: (tree) => + createProseMirrorDocFromMdastTree({ + schema, + factorySpecs, + tree, + source: markdown, + }), + }); + + return { document }; + }, + }; +}; diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 3e48434c6f9..089d30edec7 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -349,3 +349,134 @@ export function renderCodeBlock(state, node) { state.write('```'); state.closeBlock(node); } + +export function preserveUnchanged(render) { + return (state, node, parent, index) => { + const { sourceMarkdown } = node.attrs; + const same = state.options.changeTracker.get(node); + + if (same) { + state.write(sourceMarkdown); + state.closeBlock(node); + } else { + render(state, node, parent, index); + } + }; +} + +const generateBoldTags = (open = true) => { + return (_, mark) => { + const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1]; + + switch (type) { + case '**': + case '__': + return type; + // eslint-disable-next-line @gitlab/require-i18n-strings + case '<strong': + case '<b': + return (open ? openTag : closeTag)(type.substring(1)); + default: + return '**'; + } + }; +}; + +export const bold = { + open: generateBoldTags(), + close: generateBoldTags(false), + mixable: true, + expelEnclosingWhitespace: true, +}; + +const generateItalicTag = (open = true) => { + return (_, mark) => { + const type = /^(\*|_|<em|<i).*/.exec(mark.attrs.sourceMarkdown)?.[1]; + + switch (type) { + case '*': + case '_': + return type; + // eslint-disable-next-line @gitlab/require-i18n-strings + case '<em': + case '<i': + return (open ? openTag : closeTag)(type.substring(1)); + default: + return '_'; + } + }; +}; + +export const italic = { + open: generateItalicTag(), + close: generateItalicTag(false), + mixable: true, + expelEnclosingWhitespace: true, +}; + +const generateCodeTag = (open = true) => { + return (_, mark) => { + const type = /^(`|<code).*/.exec(mark.attrs.sourceMarkdown)?.[1]; + + if (type === '<code') { + return (open ? openTag : closeTag)(type.substring(1)); + } + + return '`'; + }; +}; + +export const code = { + open: generateCodeTag(), + close: generateCodeTag(false), + mixable: true, + expelEnclosingWhitespace: true, +}; + +const LINK_HTML = 'linkHtml'; +const LINK_MARKDOWN = 'linkMarkdown'; + +const linkType = (sourceMarkdown) => { + const expression = /^(\[|<a).*/.exec(sourceMarkdown)?.[1]; + + if (!expression || expression === '[') { + return LINK_MARKDOWN; + } + + return LINK_HTML; +}; + +export const link = { + open(state, mark, parent, index) { + if (isPlainURL(mark, parent, index, 1)) { + return '<'; + } + + const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs; + + if (linkType(sourceMarkdown) === LINK_MARKDOWN) { + return '['; + } + + const attrs = { href: state.esc(href || canonicalSrc) }; + + if (title) { + attrs.title = title; + } + + return openTag('a', attrs); + }, + close(state, mark, parent, index) { + if (isPlainURL(mark, parent, index, -1)) { + return '>'; + } + + const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs; + + if (linkType(sourceMarkdown) === LINK_HTML) { + return closeTag('a'); + } + + return `](${state.esc(canonicalSrc || href)}${title ? ` ${state.quote(title)}` : ''})`; + }, +}; diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js index ed2c4b39131..09f0738b51b 100644 --- a/app/assets/javascripts/content_editor/services/upload_helpers.js +++ b/app/assets/javascripts/content_editor/services/upload_helpers.js @@ -70,6 +70,8 @@ const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, const position = state.selection.from - 1; const { tr } = state; + editor.commands.setNodeSelection(position); + try { const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown }); @@ -81,6 +83,8 @@ const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, canonicalSrc, }), ); + + editor.commands.setNodeSelection(position); } catch (e) { editor.commands.deleteRange({ from: position, to: position + 1 }); eventHub.$emit('alert', { diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js index b3856b0dd74..e352fa8a9db 100644 --- a/app/assets/javascripts/content_editor/services/utils.js +++ b/app/assets/javascripts/content_editor/services/utils.js @@ -15,7 +15,7 @@ export const hasSelection = (tiptapEditor) => { * @returns {string} */ export const extractFilename = (src) => { - return src.replace(/^.*\/|\..+?$/g, ''); + return src.replace(/^.*\/|\.[^.]+?$/g, ''); }; export const readFileAsDataURL = (file) => { diff --git a/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue deleted file mode 100644 index fa0a17f3643..00000000000 --- a/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue +++ /dev/null @@ -1,246 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import $ from 'jquery'; -import { isNil } from 'lodash'; -import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; -import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; -import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; - -const toArray = (value) => (isNil(value) ? [] : [].concat(value)); -const itemsProp = (items, prop) => items.map((item) => item[prop]); -const defaultSearchFn = (searchQuery, labelProp) => (item) => - item[labelProp].toLowerCase().indexOf(searchQuery) > -1; - -export default { - components: { - DropdownButton, - DropdownSearchInput, - DropdownHiddenInput, - GlIcon, - }, - props: { - fieldName: { - type: String, - required: false, - default: '', - }, - placeholder: { - type: String, - required: false, - default: '', - }, - defaultValue: { - type: String, - required: false, - default: '', - }, - value: { - type: [Object, Array, String], - required: false, - default: () => null, - }, - labelProperty: { - type: String, - required: false, - default: 'name', - }, - valueProperty: { - type: String, - required: false, - default: 'value', - }, - items: { - type: Array, - required: false, - default: () => [], - }, - loading: { - type: Boolean, - required: false, - default: false, - }, - disabled: { - type: Boolean, - required: false, - default: false, - }, - loadingText: { - type: String, - required: false, - default: '', - }, - disabledText: { - type: String, - required: false, - default: '', - }, - hasErrors: { - type: Boolean, - required: false, - default: false, - }, - multiple: { - type: Boolean, - required: false, - default: false, - }, - errorMessage: { - type: String, - required: false, - default: '', - }, - searchFieldPlaceholder: { - type: String, - required: false, - default: '', - }, - emptyText: { - type: String, - required: false, - default: '', - }, - searchFn: { - type: Function, - required: false, - default: defaultSearchFn, - }, - }, - data() { - return { - searchQuery: '', - focusOnSearch: false, - }; - }, - computed: { - toggleText() { - if (this.loading && this.loadingText) { - return this.loadingText; - } - - if (this.disabled && this.disabledText) { - return this.disabledText; - } - - if (!this.selectedItems.length) { - return this.placeholder; - } - - return this.selectedItemsLabels; - }, - results() { - return this.getItemsOrEmptyList().filter(this.searchFn(this.searchQuery, this.labelProperty)); - }, - selectedItems() { - const valueProp = this.valueProperty; - const valueList = toArray(this.value); - const items = this.getItemsOrEmptyList(); - - return items.filter((item) => valueList.some((value) => item[valueProp] === value)); - }, - selectedItemsLabels() { - return itemsProp(this.selectedItems, this.labelProperty).join(', '); - }, - selectedItemsValues() { - return itemsProp(this.selectedItems, this.valueProperty).join(', '); - }, - }, - mounted() { - $(this.$refs.dropdown) - .on('shown.bs.dropdown', () => { - this.focusOnSearch = true; - }) - .on('hidden.bs.dropdown', () => { - this.focusOnSearch = false; - }); - }, - beforeDestroy() { - // eslint-disable-next-line @gitlab/no-global-event-off - $(this.$refs.dropdown).off(); - }, - methods: { - getItemsOrEmptyList() { - return this.items || []; - }, - selectSingle(item) { - this.$emit('input', item[this.valueProperty]); - }, - selectMultiple(item) { - const value = toArray(this.value); - const itemValue = item[this.valueProperty]; - const itemValueIndex = value.indexOf(itemValue); - - if (itemValueIndex > -1) { - value.splice(itemValueIndex, 1); - } else { - value.push(itemValue); - } - - this.$emit('input', value); - }, - isSelected(item) { - return this.selectedItems.includes(item); - }, - }, -}; -</script> - -<template> - <div> - <div ref="dropdown" class="dropdown"> - <dropdown-hidden-input :name="fieldName" :value="selectedItemsValues" /> - <dropdown-button - :class="{ 'border-danger': hasErrors }" - :is-disabled="disabled" - :is-loading="loading" - :toggle-text="toggleText" - /> - <div class="dropdown-menu dropdown-select"> - <dropdown-search-input - v-model="searchQuery" - :focused="focusOnSearch" - :placeholder-text="searchFieldPlaceholder" - /> - <div class="dropdown-content"> - <ul> - <li v-if="!results.length"> - <span class="js-empty-text menu-item">{{ emptyText }}</span> - </li> - <li v-for="item in results" :key="item.id"> - <button - v-if="multiple" - class="js-dropdown-item d-flex align-items-center" - type="button" - @click.stop.prevent="selectMultiple(item)" - > - <gl-icon - :class="[{ invisible: !isSelected(item) }, 'mr-1']" - name="mobile-issue-close" - /> - <slot name="item" :item="item">{{ item.name }}</slot> - </button> - <button - v-else - class="js-dropdown-item" - type="button" - @click.prevent="selectSingle(item)" - > - <slot name="item" :item="item">{{ item.name }}</slot> - </button> - </li> - </ul> - </div> - </div> - </div> - <span - v-if="hasErrors && errorMessage" - :class="[ - 'form-text js-eks-dropdown-error-message', - { - 'text-danger': hasErrors, - 'text-muted': !hasErrors, - }, - ]" - >{{ errorMessage }}</span - > - </div> -</template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue deleted file mode 100644 index ba170dc0e19..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue +++ /dev/null @@ -1,58 +0,0 @@ -<script> -import { mapState } from 'vuex'; -import EksClusterConfigurationForm from './eks_cluster_configuration_form.vue'; -import ServiceCredentialsForm from './service_credentials_form.vue'; - -export default { - components: { - ServiceCredentialsForm, - EksClusterConfigurationForm, - }, - props: { - gitlabManagedClusterHelpPath: { - type: String, - required: true, - }, - namespacePerEnvironmentHelpPath: { - type: String, - required: true, - }, - kubernetesIntegrationHelpPath: { - type: String, - required: true, - }, - accountAndExternalIdsHelpPath: { - type: String, - required: true, - }, - createRoleArnHelpPath: { - type: String, - required: true, - }, - externalLinkIcon: { - type: String, - required: true, - }, - }, - computed: { - ...mapState(['hasCredentials']), - }, -}; -</script> -<template> - <div class="js-create-eks-cluster"> - <eks-cluster-configuration-form - v-if="hasCredentials" - :gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath" - :namespace-per-environment-help-path="namespacePerEnvironmentHelpPath" - :kubernetes-integration-help-path="kubernetesIntegrationHelpPath" - :external-link-icon="externalLinkIcon" - /> - <service-credentials-form - v-else - :create-role-arn-help-path="createRoleArnHelpPath" - :account-and-external-ids-help-path="accountAndExternalIdsHelpPath" - :external-link-icon="externalLinkIcon" - /> - </div> -</template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue deleted file mode 100644 index 73458a463f2..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ /dev/null @@ -1,530 +0,0 @@ -<script> -import { - GlFormGroup, - GlFormInput, - GlFormCheckbox, - GlIcon, - GlLink, - GlSprintf, - GlButton, -} from '@gitlab/ui'; -import { createNamespacedHelpers, mapState, mapActions, mapGetters } from 'vuex'; -import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; -import { s__ } from '~/locale'; -import { KUBERNETES_VERSIONS } from '../constants'; - -const { mapState: mapRolesState, mapActions: mapRolesActions } = createNamespacedHelpers('roles'); -const { mapState: mapKeyPairsState, mapActions: mapKeyPairsActions } = createNamespacedHelpers( - 'keyPairs', -); -const { mapState: mapVpcsState, mapActions: mapVpcActions } = createNamespacedHelpers('vpcs'); -const { mapState: mapSubnetsState, mapActions: mapSubnetActions } = createNamespacedHelpers( - 'subnets', -); -const { - mapState: mapSecurityGroupsState, - mapActions: mapSecurityGroupsActions, -} = createNamespacedHelpers('securityGroups'); -const { mapState: mapInstanceTypesState } = createNamespacedHelpers('instanceTypes'); - -export default { - components: { - ClusterFormDropdown, - GlFormCheckbox, - GlFormGroup, - GlFormInput, - GlIcon, - GlLink, - GlSprintf, - GlButton, - }, - props: { - gitlabManagedClusterHelpPath: { - type: String, - required: true, - }, - namespacePerEnvironmentHelpPath: { - type: String, - required: true, - }, - kubernetesIntegrationHelpPath: { - type: String, - required: true, - }, - externalLinkIcon: { - type: String, - required: true, - }, - }, - i18n: { - kubernetesIntegrationHelpText: s__( - 'ClusterIntegration|Read our %{linkStart}help page%{linkEnd} on Kubernetes cluster integration.', - ), - roleDropdownHelpText: s__( - 'ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{linkStart}Amazon Web Services%{linkEnd}.', - ), - roleDropdownHelpPath: - 'https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html#create-service-role', - regionInputLabel: s__('ClusterIntegration|Cluster Region'), - regionHelpText: s__( - 'ClusterIntegration|The region the new cluster will be created in. You must reauthenticate to change regions.', - ), - keyPairDropdownHelpText: s__( - 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{linkStart}Amazon Web Services%{linkEnd}.', - ), - keyPairDropdownHelpPath: - 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair', - vpcDropdownHelpText: s__( - 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{linkStart}Amazon Web Services %{linkEnd}.', - ), - vpcDropdownHelpPath: - 'https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create', - subnetDropdownHelpText: s__( - 'ClusterIntegration|Choose the %{linkStart}subnets %{linkEnd} in your VPC where your worker nodes will run.', - ), - subnetDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#subnets', - securityGroupDropdownHelpText: s__( - 'ClusterIntegration|Choose the %{linkStart}security group%{linkEnd} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', - ), - securityGroupDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#securityGroups', - instanceTypesDropdownHelpText: s__( - 'ClusterIntegration|Choose the worker node %{linkStart}instance type%{linkEnd}.', - ), - instanceTypesDropdownHelpPath: 'https://aws.amazon.com/ec2/instance-types', - gitlabManagedClusterHelpText: s__( - 'ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{linkStart}More information%{linkEnd}', - ), - namespacePerEnvironmentHelpText: s__( - 'ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared. %{linkStart}More information%{linkEnd}', - ), - }, - computed: { - ...mapState([ - 'clusterName', - 'environmentScope', - 'kubernetesVersion', - 'selectedRegion', - 'selectedKeyPair', - 'selectedVpc', - 'selectedSubnet', - 'selectedRole', - 'selectedSecurityGroup', - 'selectedInstanceType', - 'nodeCount', - 'gitlabManagedCluster', - 'namespacePerEnvironment', - 'isCreatingCluster', - ]), - ...mapGetters(['subnetValid']), - ...mapRolesState({ - roles: 'items', - isLoadingRoles: 'isLoadingItems', - loadingRolesError: 'loadingItemsError', - }), - ...mapKeyPairsState({ - keyPairs: 'items', - isLoadingKeyPairs: 'isLoadingItems', - loadingKeyPairsError: 'loadingItemsError', - }), - ...mapVpcsState({ - vpcs: 'items', - isLoadingVpcs: 'isLoadingItems', - loadingVpcsError: 'loadingItemsError', - }), - ...mapSubnetsState({ - subnets: 'items', - isLoadingSubnets: 'isLoadingItems', - loadingSubnetsError: 'loadingItemsError', - }), - ...mapSecurityGroupsState({ - securityGroups: 'items', - isLoadingSecurityGroups: 'isLoadingItems', - loadingSecurityGroupsError: 'loadingItemsError', - }), - ...mapInstanceTypesState({ - instanceTypes: 'items', - isLoadingInstanceTypes: 'isLoadingItems', - loadingInstanceTypesError: 'loadingItemsError', - }), - kubernetesVersions() { - return KUBERNETES_VERSIONS; - }, - vpcDropdownDisabled() { - return !this.selectedRegion; - }, - keyPairDropdownDisabled() { - return !this.selectedRegion; - }, - subnetDropdownDisabled() { - return !this.selectedVpc; - }, - securityGroupDropdownDisabled() { - return !this.selectedVpc; - }, - createClusterButtonDisabled() { - return ( - !this.clusterName || - !this.environmentScope || - !this.kubernetesVersion || - !this.selectedRegion || - !this.selectedKeyPair || - !this.selectedVpc || - !this.subnetValid || - !this.selectedRole || - !this.selectedSecurityGroup || - !this.selectedInstanceType || - !this.nodeCount || - this.isCreatingCluster - ); - }, - displaySubnetError() { - return Boolean(this.loadingSubnetsError) || this.selectedSubnet?.length === 1; - }, - createClusterButtonLabel() { - return this.isCreatingCluster - ? s__('ClusterIntegration|Creating Kubernetes cluster') - : s__('ClusterIntegration|Create Kubernetes cluster'); - }, - subnetValidationErrorText() { - if (this.loadingSubnetsError) { - return s__('ClusterIntegration|Could not load subnets for the selected VPC'); - } - - return s__('ClusterIntegration|You should select at least two subnets'); - }, - }, - mounted() { - this.fetchRoles(); - this.setRegionAndFetchVpcsAndKeyPairs(); - }, - methods: { - ...mapActions([ - 'createCluster', - 'setClusterName', - 'setEnvironmentScope', - 'setKubernetesVersion', - 'setRegion', - 'setVpc', - 'setSubnet', - 'setRole', - 'setKeyPair', - 'setSecurityGroup', - 'setInstanceType', - 'setNodeCount', - 'setGitlabManagedCluster', - 'setNamespacePerEnvironment', - ]), - ...mapVpcActions({ fetchVpcs: 'fetchItems' }), - ...mapSubnetActions({ fetchSubnets: 'fetchItems' }), - ...mapRolesActions({ fetchRoles: 'fetchItems' }), - ...mapKeyPairsActions({ fetchKeyPairs: 'fetchItems' }), - ...mapSecurityGroupsActions({ fetchSecurityGroups: 'fetchItems' }), - setRegionAndFetchVpcsAndKeyPairs() { - this.setVpc({ vpc: null }); - this.setKeyPair({ keyPair: null }); - this.setSubnet({ subnet: [] }); - this.setSecurityGroup({ securityGroup: null }); - this.fetchVpcs({ region: this.selectedRegion }); - this.fetchKeyPairs({ region: this.selectedRegion }); - }, - setVpcAndFetchSubnets(vpc) { - this.setVpc({ vpc }); - this.setSubnet({ subnet: [] }); - this.setSecurityGroup({ securityGroup: null }); - this.fetchSubnets({ vpc, region: this.selectedRegion }); - this.fetchSecurityGroups({ vpc, region: this.selectedRegion }); - }, - }, -}; -</script> -<template> - <form name="eks-cluster-configuration-form"> - <h4> - {{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }} - </h4> - <div class="mb-3"> - <gl-sprintf :message="$options.i18n.kubernetesIntegrationHelpText"> - <template #link="{ content }"> - <gl-link :href="kubernetesIntegrationHelpPath"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </div> - <div class="form-group"> - <label class="label-bold" for="eks-cluster-name">{{ - s__('ClusterIntegration|Kubernetes cluster name') - }}</label> - <gl-form-input - id="eks-cluster-name" - :value="clusterName" - @input="setClusterName({ clusterName: $event })" - /> - </div> - <div class="form-group"> - <label class="label-bold" for="eks-environment-scope">{{ - s__('ClusterIntegration|Environment scope') - }}</label> - <gl-form-input - id="eks-environment-scope" - :value="environmentScope" - @input="setEnvironmentScope({ environmentScope: $event })" - /> - </div> - <div class="form-group"> - <label class="label-bold" for="eks-kubernetes-version">{{ - s__('ClusterIntegration|Kubernetes version') - }}</label> - <cluster-form-dropdown - field-id="eks-kubernetes-version" - field-name="eks-kubernetes-version" - :value="kubernetesVersion" - :items="kubernetesVersions" - :empty-text="s__('ClusterIntegration|Kubernetes version not found')" - @input="setKubernetesVersion({ kubernetesVersion: $event })" - /> - </div> - <div class="form-group"> - <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Service role') }}</label> - <cluster-form-dropdown - field-id="eks-role" - field-name="eks-role" - :value="selectedRole" - :items="roles" - :loading="isLoadingRoles" - :loading-text="s__('ClusterIntegration|Loading IAM Roles')" - :placeholder="s__('ClusterIntegration|Select service role')" - :search-field-placeholder="s__('ClusterIntegration|Search IAM Roles')" - :empty-text="s__('ClusterIntegration|No IAM Roles found')" - :has-errors="Boolean(loadingRolesError)" - :error-message="s__('ClusterIntegration|Could not load IAM roles')" - @input="setRole({ role: $event })" - /> - <p class="form-text text-muted"> - <gl-sprintf :message="$options.i18n.roleDropdownHelpText"> - <template #link="{ content }"> - <gl-link :href="$options.i18n.roleDropdownHelpPath" target="_blank"> - {{ content }} - <gl-icon name="external-link" class="gl-vertical-align-middle" /> - </gl-link> - </template> - </gl-sprintf> - </p> - </div> - <gl-form-group - :label="$options.i18n.regionInputLabel" - :description="$options.i18n.regionHelpText" - > - <gl-form-input id="eks-region" :value="selectedRegion" type="text" readonly /> - </gl-form-group> - <div class="form-group"> - <label class="label-bold" for="eks-key-pair">{{ - s__('ClusterIntegration|Key pair name') - }}</label> - <cluster-form-dropdown - field-id="eks-key-pair" - field-name="eks-key-pair" - :value="selectedKeyPair" - :items="keyPairs" - :disabled="keyPairDropdownDisabled" - :disabled-text="s__('ClusterIntegration|Select a region to choose a Key Pair')" - :loading="isLoadingKeyPairs" - :loading-text="s__('ClusterIntegration|Loading Key Pairs')" - :placeholder="s__('ClusterIntegration|Select key pair')" - :search-field-placeholder="s__('ClusterIntegration|Search Key Pairs')" - :empty-text="s__('ClusterIntegration|No Key Pairs found')" - :has-errors="Boolean(loadingKeyPairsError)" - :error-message="s__('ClusterIntegration|Could not load Key Pairs')" - @input="setKeyPair({ keyPair: $event })" - /> - <p class="form-text text-muted"> - <gl-sprintf :message="$options.i18n.keyPairDropdownHelpText"> - <template #link="{ content }"> - <gl-link :href="$options.i18n.keyPairDropdownHelpPath" target="_blank"> - {{ content }} - <gl-icon name="external-link" class="gl-vertical-align-middle" /> - </gl-link> - </template> - </gl-sprintf> - </p> - </div> - <div class="form-group"> - <label class="label-bold" for="eks-vpc">{{ s__('ClusterIntegration|VPC') }}</label> - <cluster-form-dropdown - field-id="eks-vpc" - field-name="eks-vpc" - :value="selectedVpc" - :items="vpcs" - :loading="isLoadingVpcs" - :disabled="vpcDropdownDisabled" - :disabled-text="s__('ClusterIntegration|Select a region to choose a VPC')" - :loading-text="s__('ClusterIntegration|Loading VPCs')" - :placeholder="s__('ClusterIntegration|Select a VPC')" - :search-field-placeholder="s__('ClusterIntegration|Search VPCs')" - :empty-text="s__('ClusterIntegration|No VPCs found')" - :has-errors="Boolean(loadingVpcsError)" - :error-message="s__('ClusterIntegration|Could not load VPCs for the selected region')" - @input="setVpcAndFetchSubnets($event)" - /> - <p class="form-text text-muted"> - <gl-sprintf :message="$options.i18n.vpcDropdownHelpText"> - <template #link="{ content }"> - <gl-link :href="$options.i18n.vpcDropdownHelpPath" target="_blank"> - {{ content }} - <gl-icon name="external-link" class="gl-vertical-align-middle" /> - </gl-link> - </template> - </gl-sprintf> - </p> - </div> - <div class="form-group"> - <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnets') }}</label> - <cluster-form-dropdown - field-id="eks-subnet" - field-name="eks-subnet" - multiple - :value="selectedSubnet" - :items="subnets" - :loading="isLoadingSubnets" - :disabled="subnetDropdownDisabled" - :disabled-text="s__('ClusterIntegration|Select a VPC to choose a subnet')" - :loading-text="s__('ClusterIntegration|Loading subnets')" - :placeholder="s__('ClusterIntegration|Select a subnet')" - :search-field-placeholder="s__('ClusterIntegration|Search subnets')" - :empty-text="s__('ClusterIntegration|No subnet found')" - :has-errors="displaySubnetError" - :error-message="subnetValidationErrorText" - @input="setSubnet({ subnet: $event })" - /> - <p class="form-text text-muted"> - <gl-sprintf :message="$options.i18n.subnetDropdownHelpText"> - <template #link="{ content }"> - <gl-link :href="$options.i18n.subnetDropdownHelpPath" target="_blank"> - {{ content }} - <gl-icon name="external-link" class="gl-vertical-align-middle" /> - </gl-link> - </template> - </gl-sprintf> - </p> - </div> - <div class="form-group"> - <label class="label-bold" for="eks-security-group">{{ - s__('ClusterIntegration|Security group') - }}</label> - <cluster-form-dropdown - field-id="eks-security-group" - field-name="eks-security-group" - :value="selectedSecurityGroup" - :items="securityGroups" - :loading="isLoadingSecurityGroups" - :disabled="securityGroupDropdownDisabled" - :disabled-text="s__('ClusterIntegration|Select a VPC to choose a security group')" - :loading-text="s__('ClusterIntegration|Loading security groups')" - :placeholder="s__('ClusterIntegration|Select a security group')" - :search-field-placeholder="s__('ClusterIntegration|Search security groups')" - :empty-text="s__('ClusterIntegration|No security group found')" - :has-errors="Boolean(loadingSecurityGroupsError)" - :error-message=" - s__('ClusterIntegration|Could not load security groups for the selected VPC') - " - @input="setSecurityGroup({ securityGroup: $event })" - /> - <p class="form-text text-muted"> - <gl-sprintf :message="$options.i18n.securityGroupDropdownHelpText"> - <template #link="{ content }"> - <gl-link :href="$options.i18n.securityGroupDropdownHelpPath" target="_blank"> - {{ content }} - <gl-icon name="external-link" class="gl-vertical-align-middle" /> - </gl-link> - </template> - </gl-sprintf> - </p> - </div> - <div class="form-group"> - <label class="label-bold" for="eks-instance-type">{{ - s__('ClusterIntegration|Instance type') - }}</label> - <cluster-form-dropdown - field-id="eks-instance-type" - field-name="eks-instance-type" - :value="selectedInstanceType" - :items="instanceTypes" - :loading="isLoadingInstanceTypes" - :loading-text="s__('ClusterIntegration|Loading instance types')" - :placeholder="s__('ClusterIntegration|Select an instance type')" - :search-field-placeholder="s__('ClusterIntegration|Search instance types')" - :empty-text="s__('ClusterIntegration|No instance type found')" - :has-errors="Boolean(loadingInstanceTypesError)" - :error-message="s__('ClusterIntegration|Could not load instance types')" - @input="setInstanceType({ instanceType: $event })" - /> - <p class="form-text text-muted"> - <gl-sprintf :message="$options.i18n.instanceTypesDropdownHelpText"> - <template #link="{ content }"> - <gl-link :href="$options.i18n.instanceTypesDropdownHelpPath" target="_blank"> - {{ content }} - <gl-icon name="external-link" class="gl-vertical-align-middle" /> - </gl-link> - </template> - </gl-sprintf> - </p> - </div> - <div class="form-group"> - <label class="label-bold" for="eks-node-count">{{ - s__('ClusterIntegration|Number of nodes') - }}</label> - <gl-form-input - id="eks-node-count" - type="number" - min="1" - step="1" - :value="nodeCount" - @input="setNodeCount({ nodeCount: $event })" - /> - </div> - <div class="form-group"> - <gl-form-checkbox - :checked="gitlabManagedCluster" - @input="setGitlabManagedCluster({ gitlabManagedCluster: $event })" - >{{ s__('ClusterIntegration|GitLab-managed cluster') }}</gl-form-checkbox - > - <p class="form text text-muted"> - <gl-sprintf :message="$options.i18n.gitlabManagedClusterHelpText"> - <template #link="{ content }"> - <gl-link :href="gitlabManagedClusterHelpPath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </div> - <div class="form-group"> - <gl-form-checkbox - :checked="namespacePerEnvironment" - @input="setNamespacePerEnvironment({ namespacePerEnvironment: $event })" - >{{ s__('ClusterIntegration|Namespace per environment') }}</gl-form-checkbox - > - <p class="form text text-muted"> - <gl-sprintf :message="$options.i18n.namespacePerEnvironmentHelpText"> - <template #link="{ content }"> - <gl-link :href="namespacePerEnvironmentHelpPath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </div> - <div class="form-group"> - <gl-button - variant="success" - category="primary" - class="js-create-cluster" - :disabled="createClusterButtonDisabled" - :loading="isCreatingCluster" - @click="createCluster()" - > - {{ createClusterButtonLabel }} - </gl-button> - </div> - </form> -</template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue deleted file mode 100644 index 004c2e26c4e..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue +++ /dev/null @@ -1,182 +0,0 @@ -<script> -import { GlButton, GlFormGroup, GlFormInput, GlIcon, GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; -import { s__, __ } from '~/locale'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import { DEFAULT_REGION } from '../constants'; - -export default { - components: { - GlButton, - GlFormGroup, - GlFormInput, - GlIcon, - GlLink, - GlSprintf, - ClipboardButton, - GlAlert, - }, - props: { - accountAndExternalIdsHelpPath: { - type: String, - required: true, - }, - createRoleArnHelpPath: { - type: String, - required: true, - }, - externalLinkIcon: { - type: String, - required: true, - }, - }, - i18n: { - regionInputLabel: s__('ClusterIntegration|Cluster Region'), - regionHelpPath: 'https://aws.amazon.com/about-aws/global-infrastructure/regions_az/', - regionHelpText: s__( - 'ClusterIntegration|Select the region you want to create the new cluster in. Make sure you have access to this region for your role to be able to authenticate. If no region is selected, we will use %{codeStart}DEFAULT_REGION%{codeEnd}. Learn more about %{linkStart}Regions%{linkEnd}.', - ), - accountAndExternalIdsHelpText: s__( - 'ClusterIntegration|The Amazon Resource Name (ARN) associated with your role. If you do not have a provisioned role, first create one on %{awsLinkStart}Amazon Web Services %{awsLinkEnd} using the above account and external IDs. %{moreInfoStart}More information%{moreInfoEnd}', - ), - regionHelpTextDefaultRegion: DEFAULT_REGION, - }, - data() { - return { - roleArn: this.$store.state.roleArn, - selectedRegion: this.$store.state.selectedRegion, - }; - }, - computed: { - ...mapState(['accountId', 'externalId', 'isCreatingRole', 'createRoleError']), - submitButtonDisabled() { - return this.isCreatingRole || !this.roleArn; - }, - submitButtonLabel() { - return this.isCreatingRole - ? __('Authenticating') - : s__('ClusterIntegration|Authenticate with AWS'); - }, - awsHelpLink() { - return 'https://console.aws.amazon.com/iam/home?#roles'; - }, - }, - methods: { - ...mapActions(['createRole']), - }, -}; -</script> -<template> - <form name="service-credentials-form"> - <h4>{{ s__('ClusterIntegration|Authenticate with Amazon Web Services') }}</h4> - <p> - {{ - s__( - 'ClusterIntegration|You must grant access to your organization’s AWS resources in order to create a new EKS cluster. To grant access, create a provision role using the account and external ID below and provide us the ARN.', - ) - }} - </p> - <gl-alert - v-if="createRoleError" - class="js-invalid-credentials gl-mb-5" - variant="danger" - :dismissible="false" - > - {{ createRoleError }} - </gl-alert> - <div class="form-row"> - <div class="form-group col-md-6"> - <label for="gitlab-account-id">{{ __('Account ID') }}</label> - <div class="input-group"> - <gl-form-input id="gitlab-account-id" type="text" readonly :value="accountId" /> - <div class="input-group-append"> - <clipboard-button - :text="accountId" - :title="__('Copy Account ID to clipboard')" - class="input-group-text js-copy-account-id-button" - /> - </div> - </div> - </div> - <div class="form-group col-md-6"> - <label for="eks-external-id">{{ __('External ID') }}</label> - <div class="input-group"> - <gl-form-input id="eks-external-id" type="text" readonly :value="externalId" /> - <div class="input-group-append"> - <clipboard-button - :text="externalId" - :title="__('Copy External ID to clipboard')" - class="input-group-text js-copy-external-id-button" - /> - </div> - </div> - </div> - <div class="col-12 mb-3 mt-n3"> - <p class="form-text text-muted"> - <gl-sprintf :message="$options.i18n.accountAndExternalIdsHelpText"> - <template #awsLink="{ content }"> - <gl-link :href="awsHelpLink" target="_blank"> - {{ content }} - <gl-icon name="external-link" class="gl-vertical-align-middle" /> - </gl-link> - </template> - <template #moreInfo="{ content }"> - <gl-link :href="accountAndExternalIdsHelpPath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </div> - </div> - <div class="form-group"> - <label for="eks-provision-role-arn">{{ s__('ClusterIntegration|Provision Role ARN') }}</label> - <gl-form-input id="eks-provision-role-arn" v-model="roleArn" /> - <p class="form-text text-muted"> - <gl-sprintf :message="$options.i18n.accountAndExternalIdsHelpText"> - <template #awsLink="{ content }"> - <gl-link :href="awsHelpLink" target="_blank"> - {{ content }} - <gl-icon name="external-link" class="gl-vertical-align-middle" /> - </gl-link> - </template> - <template #moreInfo="{ content }"> - <gl-link :href="accountAndExternalIdsHelpPath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </div> - - <gl-form-group :label="$options.i18n.regionInputLabel"> - <gl-form-input id="eks-region" v-model="selectedRegion" type="text" /> - - <template #description> - <gl-sprintf :message="$options.i18n.regionHelpText"> - <template #code> - <code>{{ $options.i18n.regionHelpTextDefaultRegion }}</code> - </template> - - <template #link="{ content }"> - <gl-link :href="$options.i18n.regionHelpPath" target="_blank"> - {{ content }} - <gl-icon name="external-link" /> - </gl-link> - </template> - </gl-sprintf> - </template> - </gl-form-group> - - <gl-button - variant="success" - category="primary" - type="submit" - :disabled="submitButtonDisabled" - :loading="isCreatingRole" - @click.prevent="createRole({ roleArn, selectedRegion, externalId })" - > - {{ submitButtonLabel }} - </gl-button> - </form> -</template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js deleted file mode 100644 index 3ed0f050301..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js +++ /dev/null @@ -1,9 +0,0 @@ -export const DEFAULT_REGION = 'us-east-2'; - -export const KUBERNETES_VERSIONS = [ - { name: '1.16', value: '1.16' }, - { name: '1.17', value: '1.17' }, - { name: '1.18', value: '1.18' }, - { name: '1.19', value: '1.19' }, - { name: '1.20', value: '1.20', default: true }, -]; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js deleted file mode 100644 index 38b7eefd15b..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/index.js +++ /dev/null @@ -1,55 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import CreateEksCluster from './components/create_eks_cluster.vue'; -import createStore from './store'; - -Vue.use(Vuex); - -export default (el) => { - const { - gitlabManagedClusterHelpPath, - namespacePerEnvironmentHelpPath, - kubernetesIntegrationHelpPath, - accountAndExternalIdsHelpPath, - createRoleArnHelpPath, - externalId, - accountId, - instanceTypes, - hasCredentials, - createRolePath, - createClusterPath, - externalLinkIcon, - roleArn, - } = el.dataset; - - return new Vue({ - el, - store: createStore({ - initialState: { - hasCredentials: parseBoolean(hasCredentials), - externalId, - accountId, - instanceTypes: JSON.parse(instanceTypes), - createRolePath, - createClusterPath, - roleArn, - }, - }), - components: { - CreateEksCluster, - }, - render(createElement) { - return createElement('create-eks-cluster', { - props: { - gitlabManagedClusterHelpPath, - namespacePerEnvironmentHelpPath, - kubernetesIntegrationHelpPath, - accountAndExternalIdsHelpPath, - createRoleArnHelpPath, - externalLinkIcon, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js deleted file mode 100644 index bd9554521b8..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js +++ /dev/null @@ -1,79 +0,0 @@ -import EC2 from 'aws-sdk/clients/ec2'; -import IAM from 'aws-sdk/clients/iam'; -import AWS from 'aws-sdk/global'; - -const lookupVpcName = ({ Tags: tags, VpcId: id }) => { - const nameTag = tags.find(({ Key: key }) => key === 'Name'); - - return nameTag ? nameTag.Value : id; -}; - -export const setAWSConfig = ({ awsCredentials }) => { - AWS.config = awsCredentials; -}; - -export const fetchRoles = () => { - const iam = new IAM(); - - return iam - .listRoles() - .promise() - .then(({ Roles: roles }) => roles.map(({ RoleName: name, Arn: value }) => ({ name, value }))); -}; - -export const fetchKeyPairs = ({ region }) => { - const ec2 = new EC2({ region }); - - return ec2 - .describeKeyPairs() - .promise() - .then(({ KeyPairs: keyPairs }) => keyPairs.map(({ KeyName: name }) => ({ name, value: name }))); -}; - -export const fetchVpcs = ({ region }) => { - const ec2 = new EC2({ region }); - - return ec2 - .describeVpcs() - .promise() - .then(({ Vpcs: vpcs }) => - vpcs.map((vpc) => ({ - value: vpc.VpcId, - name: lookupVpcName(vpc), - })), - ); -}; - -export const fetchSubnets = ({ vpc, region }) => { - const ec2 = new EC2({ region }); - - return ec2 - .describeSubnets({ - Filters: [ - { - Name: 'vpc-id', - Values: [vpc], - }, - ], - }) - .promise() - .then(({ Subnets: subnets }) => subnets.map(({ SubnetId: id }) => ({ value: id, name: id }))); -}; - -export const fetchSecurityGroups = ({ region, vpc }) => { - const ec2 = new EC2({ region }); - - return ec2 - .describeSecurityGroups({ - Filters: [ - { - Name: 'vpc-id', - Values: [vpc], - }, - ], - }) - .promise() - .then(({ SecurityGroups: securityGroups }) => - securityGroups.map(({ GroupName: name, GroupId: value }) => ({ name, value })), - ); -}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js deleted file mode 100644 index cd8212a40f9..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ /dev/null @@ -1,148 +0,0 @@ -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { DEFAULT_REGION } from '../constants'; -import { setAWSConfig } from '../services/aws_services_facade'; -import * as types from './mutation_types'; - -const getErrorMessage = (data) => { - const errorKey = Object.keys(data)[0]; - - return data[errorKey][0]; -}; - -export const setClusterName = ({ commit }, payload) => { - commit(types.SET_CLUSTER_NAME, payload); -}; - -export const setEnvironmentScope = ({ commit }, payload) => { - commit(types.SET_ENVIRONMENT_SCOPE, payload); -}; - -export const setKubernetesVersion = ({ commit }, payload) => { - commit(types.SET_KUBERNETES_VERSION, payload); -}; - -export const createRole = ({ dispatch, state: { createRolePath } }, payload) => { - dispatch('requestCreateRole'); - - const region = payload.selectedRegion || DEFAULT_REGION; - - return axios - .post(createRolePath, { - role_arn: payload.roleArn, - role_external_id: payload.externalId, - region, - }) - .then(({ data }) => { - const awsData = { - ...convertObjectPropsToCamelCase(data), - region, - }; - - dispatch('createRoleSuccess', awsData); - }) - .catch((error) => { - let message = error; - if (error?.response?.data?.message) { - message = error.response.data.message; - } - dispatch('createRoleError', { error: message }); - }); -}; - -export const requestCreateRole = ({ commit }) => { - commit(types.REQUEST_CREATE_ROLE); -}; - -export const createRoleSuccess = ({ dispatch, commit }, awsCredentials) => { - dispatch('setRegion', { region: awsCredentials.region }); - setAWSConfig({ awsCredentials }); - commit(types.CREATE_ROLE_SUCCESS); -}; - -export const createRoleError = ({ commit }, payload) => { - commit(types.CREATE_ROLE_ERROR, payload); -}; - -export const createCluster = ({ dispatch, state }) => { - dispatch('requestCreateCluster'); - - return axios - .post(state.createClusterPath, { - name: state.clusterName, - environment_scope: state.environmentScope, - managed: state.gitlabManagedCluster, - namespace_per_environment: state.namespacePerEnvironment, - provider_aws_attributes: { - kubernetes_version: state.kubernetesVersion, - region: state.selectedRegion, - vpc_id: state.selectedVpc, - subnet_ids: state.selectedSubnet, - role_arn: state.selectedRole, - key_name: state.selectedKeyPair, - security_group_id: state.selectedSecurityGroup, - instance_type: state.selectedInstanceType, - num_nodes: state.nodeCount, - }, - }) - .then(({ headers: { location } }) => dispatch('createClusterSuccess', location)) - .catch(({ response: { data } }) => { - dispatch('createClusterError', data); - }); -}; - -export const requestCreateCluster = ({ commit }) => { - commit(types.REQUEST_CREATE_CLUSTER); -}; - -export const createClusterSuccess = (_, location) => { - window.location.assign(location); -}; - -export const createClusterError = ({ commit }, error) => { - commit(types.CREATE_CLUSTER_ERROR, error); - createFlash({ - message: getErrorMessage(error), - }); -}; - -export const setRegion = ({ commit }, payload) => { - commit(types.SET_REGION, payload); -}; - -export const setKeyPair = ({ commit }, payload) => { - commit(types.SET_KEY_PAIR, payload); -}; - -export const setVpc = ({ commit }, payload) => { - commit(types.SET_VPC, payload); -}; - -export const setSubnet = ({ commit }, payload) => { - commit(types.SET_SUBNET, payload); -}; - -export const setRole = ({ commit }, payload) => { - commit(types.SET_ROLE, payload); -}; - -export const setSecurityGroup = ({ commit }, payload) => { - commit(types.SET_SECURITY_GROUP, payload); -}; - -export const setGitlabManagedCluster = ({ commit }, payload) => { - commit(types.SET_GITLAB_MANAGED_CLUSTER, payload); -}; - -export const setNamespacePerEnvironment = ({ commit }, payload) => { - commit(types.SET_NAMESPACE_PER_ENVIRONMENT, payload); -}; - -export const setInstanceType = ({ commit }, payload) => { - commit(types.SET_INSTANCE_TYPE, payload); -}; - -export const setNodeCount = ({ commit }, payload) => { - commit(types.SET_NODE_COUNT, payload); -}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/getters.js b/app/assets/javascripts/create_cluster/eks_cluster/store/getters.js deleted file mode 100644 index d8489ca31cf..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/getters.js +++ /dev/null @@ -1,2 +0,0 @@ -export const subnetValid = ({ selectedSubnet }) => - Array.isArray(selectedSubnet) && selectedSubnet.length >= 2; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js deleted file mode 100644 index ed054989771..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js +++ /dev/null @@ -1,49 +0,0 @@ -import Vuex from 'vuex'; -import clusterDropdownStore from '~/create_cluster/store/cluster_dropdown'; -import { - fetchRoles, - fetchKeyPairs, - fetchVpcs, - fetchSubnets, - fetchSecurityGroups, -} from '../services/aws_services_facade'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import state from './state'; - -const createStore = ({ initialState }) => - new Vuex.Store({ - actions, - getters, - mutations, - state: Object.assign(state(), initialState), - modules: { - roles: { - namespaced: true, - ...clusterDropdownStore({ fetchFn: fetchRoles }), - }, - keyPairs: { - namespaced: true, - ...clusterDropdownStore({ fetchFn: fetchKeyPairs }), - }, - vpcs: { - namespaced: true, - ...clusterDropdownStore({ fetchFn: fetchVpcs }), - }, - subnets: { - namespaced: true, - ...clusterDropdownStore({ fetchFn: fetchSubnets }), - }, - securityGroups: { - namespaced: true, - ...clusterDropdownStore({ fetchFn: fetchSecurityGroups }), - }, - instanceTypes: { - namespaced: true, - ...clusterDropdownStore({ initialState: { items: initialState.instanceTypes } }), - }, - }, - }); - -export default createStore; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js deleted file mode 100644 index 4a48195a27b..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js +++ /dev/null @@ -1,19 +0,0 @@ -export const SET_CLUSTER_NAME = 'SET_CLUSTER_NAME'; -export const SET_ENVIRONMENT_SCOPE = 'SET_ENVIRONMENT_SCOPE'; -export const SET_KUBERNETES_VERSION = 'SET_KUBERNETES_VERSION'; -export const SET_REGION = 'SET_REGION'; -export const SET_VPC = 'SET_VPC'; -export const SET_KEY_PAIR = 'SET_KEY_PAIR'; -export const SET_SUBNET = 'SET_SUBNET'; -export const SET_ROLE = 'SET_ROLE'; -export const SET_SECURITY_GROUP = 'SET_SECURITY_GROUP'; -export const SET_INSTANCE_TYPE = 'SET_INSTANCE_TYPE'; -export const SET_NODE_COUNT = 'SET_NODE_COUNT'; -export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER'; -export const SET_NAMESPACE_PER_ENVIRONMENT = 'SET_NAMESPACE_PER_ENVIRONMENT'; -export const REQUEST_CREATE_ROLE = 'REQUEST_CREATE_ROLE'; -export const CREATE_ROLE_SUCCESS = 'CREATE_ROLE_SUCCESS'; -export const CREATE_ROLE_ERROR = 'CREATE_ROLE_ERROR'; -export const REQUEST_CREATE_CLUSTER = 'REQUEST_CREATE_CLUSTER'; -export const CREATE_CLUSTER_SUCCESS = 'CREATE_CLUSTER_SUCCESS'; -export const CREATE_CLUSTER_ERROR = 'CREATE_CLUSTER_ERROR'; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js deleted file mode 100644 index f57236e0e31..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js +++ /dev/null @@ -1,66 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.SET_CLUSTER_NAME](state, { clusterName }) { - state.clusterName = clusterName; - }, - [types.SET_ENVIRONMENT_SCOPE](state, { environmentScope }) { - state.environmentScope = environmentScope; - }, - [types.SET_KUBERNETES_VERSION](state, { kubernetesVersion }) { - state.kubernetesVersion = kubernetesVersion; - }, - [types.SET_REGION](state, { region }) { - state.selectedRegion = region; - }, - [types.SET_KEY_PAIR](state, { keyPair }) { - state.selectedKeyPair = keyPair; - }, - [types.SET_VPC](state, { vpc }) { - state.selectedVpc = vpc; - }, - [types.SET_SUBNET](state, { subnet }) { - state.selectedSubnet = subnet; - }, - [types.SET_ROLE](state, { role }) { - state.selectedRole = role; - }, - [types.SET_SECURITY_GROUP](state, { securityGroup }) { - state.selectedSecurityGroup = securityGroup; - }, - [types.SET_INSTANCE_TYPE](state, { instanceType }) { - state.selectedInstanceType = instanceType; - }, - [types.SET_NODE_COUNT](state, { nodeCount }) { - state.nodeCount = nodeCount; - }, - [types.SET_GITLAB_MANAGED_CLUSTER](state, { gitlabManagedCluster }) { - state.gitlabManagedCluster = gitlabManagedCluster; - }, - [types.SET_NAMESPACE_PER_ENVIRONMENT](state, { namespacePerEnvironment }) { - state.namespacePerEnvironment = namespacePerEnvironment; - }, - [types.REQUEST_CREATE_ROLE](state) { - state.isCreatingRole = true; - state.createRoleError = null; - state.hasCredentials = false; - }, - [types.CREATE_ROLE_SUCCESS](state) { - state.isCreatingRole = false; - state.createRoleError = null; - state.hasCredentials = true; - }, - [types.CREATE_ROLE_ERROR](state, { error }) { - state.isCreatingRole = false; - state.createRoleError = error; - state.hasCredentials = false; - }, - [types.REQUEST_CREATE_CLUSTER](state) { - state.isCreatingCluster = true; - state.createClusterError = null; - }, - [types.CREATE_CLUSTER_ERROR](state, { error }) { - state.isCreatingCluster = false; - state.createClusterError = error; - }, -}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js deleted file mode 100644 index c906ddf9011..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js +++ /dev/null @@ -1,34 +0,0 @@ -import { KUBERNETES_VERSIONS } from '../constants'; - -const kubernetesVersion = KUBERNETES_VERSIONS.find((version) => version.default).value; - -export default () => ({ - createRolePath: null, - - isCreatingRole: false, - roleCreated: false, - createRoleError: false, - - accountId: '', - externalId: '', - - roleArn: '', - - clusterName: '', - environmentScope: '*', - kubernetesVersion, - selectedRegion: '', - selectedRole: '', - selectedKeyPair: '', - selectedVpc: '', - selectedSubnet: [], - selectedSecurityGroup: '', - selectedInstanceType: 'm5.large', - nodeCount: '3', - - isCreatingCluster: false, - createClusterError: false, - - gitlabManagedCluster: true, - namespacePerEnvironment: true, -}); diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js deleted file mode 100644 index 1246fdb19d7..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js +++ /dev/null @@ -1,70 +0,0 @@ -import { GlLoadingIcon } from '@gitlab/ui'; -import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; -import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; -import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; - -import store from '../store'; - -export default { - store, - components: { - DropdownButton, - DropdownSearchInput, - DropdownHiddenInput, - GlLoadingIcon, - }, - props: { - fieldId: { - type: String, - required: true, - }, - fieldName: { - type: String, - required: true, - }, - defaultValue: { - type: String, - required: false, - default: '', - }, - }, - data() { - return { - isLoading: false, - hasErrors: false, - searchQuery: '', - gapiError: '', - }; - }, - computed: { - results() { - if (!this.items) { - return []; - } - - return this.items.filter((item) => item.name.toLowerCase().indexOf(this.searchQuery) > -1); - }, - }, - methods: { - fetchSuccessHandler() { - if (this.defaultValue) { - const itemToSelect = this.items.find((item) => item.name === this.defaultValue); - - if (itemToSelect) { - this.setItem(itemToSelect.name); - } - } - - this.isLoading = false; - this.hasErrors = false; - }, - fetchFailureHandler(resp) { - this.isLoading = false; - this.hasErrors = true; - - if (resp.result && resp.result.error) { - this.gapiError = resp.result.error.message; - } - }, - }, -}; diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue deleted file mode 100644 index 23c477bfbfd..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue +++ /dev/null @@ -1,112 +0,0 @@ -<script> -import { mapState, mapGetters, mapActions } from 'vuex'; -import { sprintf, s__ } from '~/locale'; - -import gkeDropdownMixin from './gke_dropdown_mixin'; - -export default { - name: 'GkeMachineTypeDropdown', - mixins: [gkeDropdownMixin], - computed: { - ...mapState([ - 'isValidatingProjectBilling', - 'projectHasBillingEnabled', - 'selectedZone', - 'selectedMachineType', - ]), - ...mapState({ items: 'machineTypes' }), - ...mapGetters(['hasZone', 'hasMachineType']), - isDisabled() { - return ( - this.isLoading || - this.isValidatingProjectBilling || - !this.projectHasBillingEnabled || - !this.hasZone - ); - }, - toggleText() { - if (this.isLoading) { - return s__('ClusterIntegration|Fetching machine types'); - } - - if (this.selectedMachineType) { - return this.selectedMachineType; - } - - if (!this.projectHasBillingEnabled && !this.hasZone) { - return s__('ClusterIntegration|Select project and zone to choose machine type'); - } - - return !this.hasZone - ? s__('ClusterIntegration|Select zone to choose machine type') - : s__('ClusterIntegration|Select machine type'); - }, - errorMessage() { - return sprintf( - s__( - 'ClusterIntegration|An error occurred while trying to fetch zone machine types: %{error}', - ), - { error: this.gapiError }, - ); - }, - }, - watch: { - selectedZone() { - this.hasErrors = false; - - if (this.hasZone) { - this.isLoading = true; - - this.fetchMachineTypes().then(this.fetchSuccessHandler).catch(this.fetchFailureHandler); - } - }, - }, - methods: { - ...mapActions(['fetchMachineTypes']), - ...mapActions({ setItem: 'setMachineType' }), - }, -}; -</script> - -<template> - <div> - <div class="js-gcp-machine-type-dropdown dropdown"> - <dropdown-hidden-input :name="fieldName" :value="selectedMachineType" /> - <dropdown-button - :class="{ 'border-danger': hasErrors }" - :is-disabled="isDisabled" - :is-loading="isLoading" - :toggle-text="toggleText" - /> - <div class="dropdown-menu dropdown-select"> - <dropdown-search-input - v-model="searchQuery" - :placeholder-text="s__('ClusterIntegration|Search machine types')" - /> - <div class="dropdown-content"> - <ul> - <li v-show="!results.length"> - <span class="menu-item"> - {{ s__('ClusterIntegration|No machine types matched your search') }} - </span> - </li> - <li v-for="result in results" :key="result.id"> - <button type="button" @click.prevent="setItem(result.name)">{{ result.name }}</button> - </li> - </ul> - </div> - <div class="dropdown-loading"><gl-loading-icon size="sm" /></div> - </div> - </div> - <span - v-if="hasErrors" - :class="{ - 'text-danger': hasErrors, - 'text-muted': !hasErrors, - }" - class="form-text" - > - {{ errorMessage }} - </span> - </div> -</template> diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue deleted file mode 100644 index 8f18ac29c0f..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue +++ /dev/null @@ -1,53 +0,0 @@ -<script> -import { createNamespacedHelpers, mapState, mapGetters, mapActions } from 'vuex'; - -import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; - -const { mapState: mapDropdownState } = createNamespacedHelpers('networks'); -const { mapActions: mapSubnetworkActions } = createNamespacedHelpers('subnetworks'); - -export default { - components: { - ClusterFormDropdown, - }, - props: { - fieldName: { - type: String, - required: true, - }, - }, - computed: { - ...mapState(['selectedNetwork']), - ...mapDropdownState(['items', 'isLoadingItems', 'loadingItemsError']), - ...mapGetters(['hasZone', 'projectId', 'region']), - }, - methods: { - ...mapActions(['setNetwork', 'setSubnetwork']), - ...mapSubnetworkActions({ fetchSubnetworks: 'fetchItems' }), - setNetworkAndFetchSubnetworks(network) { - const { projectId: project, region } = this; - - this.setSubnetwork(''); - this.setNetwork(network); - this.fetchSubnetworks({ project, region, network: network.selfLink }); - }, - }, -}; -</script> -<template> - <cluster-form-dropdown - :field-name="fieldName" - :value="selectedNetwork" - :items="items" - :disabled="!hasZone" - :loading="isLoadingItems" - :has-errors="Boolean(loadingItemsError)" - :loading-text="s__('ClusterIntegration|Loading networks')" - :placeholder="s__('ClusterIntegration|Select a network')" - :search-field-placeholder="s__('ClusterIntegration|Search networks')" - :empty-text="s__('ClusterIntegration|No networks found')" - :error-message="s__('ClusterIntegration|Could not load networks')" - :disabled-text="s__('ClusterIntegration|Select a zone to choose a network')" - @input="setNetworkAndFetchSubnetworks" - /> -</template> diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue deleted file mode 100644 index aba6dd4b493..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue +++ /dev/null @@ -1,194 +0,0 @@ -<script> -import { GlSprintf, GlLink, GlIcon } from '@gitlab/ui'; -import { mapState, mapGetters, mapActions } from 'vuex'; -import { s__ } from '~/locale'; - -import gkeDropdownMixin from './gke_dropdown_mixin'; - -export default { - name: 'GkeProjectIdDropdown', - components: { - GlSprintf, - GlLink, - GlIcon, - }, - mixins: [gkeDropdownMixin], - props: { - docsUrl: { - type: String, - required: true, - }, - }, - computed: { - ...mapState(['selectedProject', 'isValidatingProjectBilling', 'projectHasBillingEnabled']), - ...mapState({ items: 'projects' }), - ...mapGetters(['hasProject']), - hasOneProject() { - return this.items && this.items.length === 1; - }, - isDisabled() { - return ( - this.isLoading || this.isValidatingProjectBilling || (this.items && this.items.length < 2) - ); - }, - toggleText() { - if (this.isValidatingProjectBilling) { - return s__('ClusterIntegration|Validating project billing status'); - } - - if (this.isLoading) { - return s__('ClusterIntegration|Fetching projects'); - } - - if (this.hasProject) { - return this.selectedProject.name; - } - - if (!this.items) { - return s__('ClusterIntegration|No projects found'); - } - - return s__('ClusterIntegration|Select project'); - }, - helpText() { - if (this.hasErrors) { - return this.errorMessage; - } - - if (!this.items) { - return s__( - 'ClusterIntegration|We were unable to fetch any projects. Ensure that you have a project on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.', - ); - } - - return this.items.length - ? s__( - 'ClusterIntegration|To use a new project, first create one on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.', - ) - : s__( - 'ClusterIntegration|To create a cluster, first create a project on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.', - ); - }, - errorMessage() { - if (!this.projectHasBillingEnabled) { - if (this.gapiError) { - return s__( - 'ClusterIntegration|We could not verify that one of your projects on GCP has billing enabled. Please try again.', - ); - } - - return s__( - 'ClusterIntegration|This project does not have billing enabled. To create a cluster, %{linkToBillingStart}enable billing%{linkToBillingEnd} and try again.', - ); - } - - return s__( - 'ClusterIntegration|An error occurred while trying to fetch your projects: %{error}', - ); - }, - }, - watch: { - selectedProject() { - this.setIsValidatingProjectBilling(true); - - this.validateProjectBilling() - .then(this.validateProjectBillingSuccessHandler) - .catch(this.validateProjectBillingFailureHandler); - }, - }, - created() { - this.isLoading = true; - - this.fetchProjects().then(this.fetchSuccessHandler).catch(this.fetchFailureHandler); - }, - methods: { - ...mapActions(['fetchProjects', 'setIsValidatingProjectBilling', 'validateProjectBilling']), - ...mapActions({ setItem: 'setProject' }), - fetchSuccessHandler() { - if (this.defaultValue) { - const projectToSelect = this.items.find((item) => item.projectId === this.defaultValue); - - if (projectToSelect) { - this.setItem(projectToSelect); - } - } else if (this.items.length === 1) { - this.setItem(this.items[0]); - } - - this.isLoading = false; - this.hasErrors = false; - }, - validateProjectBillingSuccessHandler() { - this.hasErrors = !this.projectHasBillingEnabled; - }, - validateProjectBillingFailureHandler(resp) { - this.hasErrors = true; - - this.gapiError = resp.result ? resp.result.error.message : resp; - }, - }, -}; -</script> - -<template> - <div> - <div class="js-gcp-project-id-dropdown dropdown"> - <dropdown-hidden-input :name="fieldName" :value="selectedProject.projectId" /> - <dropdown-button - :class="{ - 'border-danger': hasErrors, - 'read-only': hasOneProject, - }" - :is-disabled="isDisabled" - :is-loading="isLoading" - :toggle-text="toggleText" - /> - <div class="dropdown-menu dropdown-select"> - <dropdown-search-input - v-model="searchQuery" - :placeholder-text="s__('ClusterIntegration|Search projects')" - /> - <div class="dropdown-content"> - <ul> - <li v-show="!results.length"> - <span class="menu-item"> - {{ s__('ClusterIntegration|No projects matched your search') }} - </span> - </li> - <li v-for="result in results" :key="result.project_number"> - <button type="button" @click.prevent="setItem(result)">{{ result.name }}</button> - </li> - </ul> - </div> - <div class="dropdown-loading"><gl-loading-icon size="sm" /></div> - </div> - </div> - <span - :class="{ - 'text-danger': hasErrors, - 'text-muted': !hasErrors, - }" - class="form-text" - > - <gl-sprintf :message="helpText"> - <template #linkToBilling="{ content }"> - <gl-link - :href="'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral'" - target="_blank" - >{{ content }} <gl-icon name="external-link" - /></gl-link> - </template> - - <template #docsLink="{ content }"> - <gl-link :href="docsUrl" target="_blank" - >{{ content }} <gl-icon name="external-link" - /></gl-link> - </template> - - <template #error> - {{ gapiError }} - </template> - </gl-sprintf> - </span> - </div> -</template> diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_submit_button.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_submit_button.vue deleted file mode 100644 index a7e08a5e97f..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_submit_button.vue +++ /dev/null @@ -1,18 +0,0 @@ -<script> -import { mapGetters } from 'vuex'; - -export default { - computed: { - ...mapGetters(['hasValidData']), - }, -}; -</script> -<template> - <button - type="submit" - :disabled="!hasValidData" - class="js-gke-cluster-creation-submit btn btn-success" - > - {{ s__('ClusterIntegration|Create Kubernetes cluster') }} - </button> -</template> diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue deleted file mode 100644 index dab4adc3789..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue +++ /dev/null @@ -1,44 +0,0 @@ -<script> -import { createNamespacedHelpers, mapState, mapGetters, mapActions } from 'vuex'; - -import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; - -const { mapState: mapDropdownState } = createNamespacedHelpers('subnetworks'); - -export default { - components: { - ClusterFormDropdown, - }, - props: { - fieldName: { - type: String, - required: true, - }, - }, - computed: { - ...mapState(['selectedSubnetwork']), - ...mapDropdownState(['items', 'isLoadingItems', 'loadingItemsError']), - ...mapGetters(['hasNetwork']), - }, - methods: { - ...mapActions(['setSubnetwork']), - }, -}; -</script> -<template> - <cluster-form-dropdown - :field-name="fieldName" - :value="selectedSubnetwork" - :items="items" - :disabled="!hasNetwork" - :loading="isLoadingItems" - :has-errors="Boolean(loadingItemsError)" - :loading-text="s__('ClusterIntegration|Loading subnetworks')" - :placeholder="s__('ClusterIntegration|Select a subnetwork')" - :search-field-placeholder="s__('ClusterIntegration|Search subnetworks')" - :empty-text="s__('ClusterIntegration|No subnetworks found')" - :error-message="s__('ClusterIntegration|Could not load subnetworks')" - :disabled-text="s__('ClusterIntegration|Select a network to choose a subnetwork')" - @input="setSubnetwork" - /> -</template> diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue deleted file mode 100644 index 027ce74753e..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue +++ /dev/null @@ -1,101 +0,0 @@ -<script> -import { mapState, mapActions } from 'vuex'; -import { sprintf, s__ } from '~/locale'; - -import gkeDropdownMixin from './gke_dropdown_mixin'; - -export default { - name: 'GkeZoneDropdown', - mixins: [gkeDropdownMixin], - computed: { - ...mapState([ - 'selectedProject', - 'selectedZone', - 'projects', - 'isValidatingProjectBilling', - 'projectHasBillingEnabled', - ]), - ...mapState({ items: 'zones' }), - isDisabled() { - return this.isLoading || this.isValidatingProjectBilling || !this.projectHasBillingEnabled; - }, - toggleText() { - if (this.isLoading) { - return s__('ClusterIntegration|Fetching zones'); - } - - if (this.selectedZone) { - return this.selectedZone; - } - - return !this.projectHasBillingEnabled - ? s__('ClusterIntegration|Select project to choose zone') - : s__('ClusterIntegration|Select zone'); - }, - errorMessage() { - return sprintf( - s__('ClusterIntegration|An error occurred while trying to fetch project zones: %{error}'), - { error: this.gapiError }, - ); - }, - }, - watch: { - isValidatingProjectBilling(isValidating) { - this.hasErrors = false; - - if (!isValidating && this.projectHasBillingEnabled) { - this.isLoading = true; - - this.fetchZones().then(this.fetchSuccessHandler).catch(this.fetchFailureHandler); - } - }, - }, - methods: { - ...mapActions(['fetchZones']), - ...mapActions({ setItem: 'setZone' }), - }, -}; -</script> - -<template> - <div> - <div class="js-gcp-zone-dropdown dropdown"> - <dropdown-hidden-input :name="fieldName" :value="selectedZone" /> - <dropdown-button - :class="{ 'border-danger': hasErrors }" - :is-disabled="isDisabled" - :is-loading="isLoading" - :toggle-text="toggleText" - /> - <div class="dropdown-menu dropdown-select"> - <dropdown-search-input - v-model="searchQuery" - :placeholder-text="s__('ClusterIntegration|Search zones')" - /> - <div class="dropdown-content"> - <ul> - <li v-show="!results.length"> - <span class="menu-item"> - {{ s__('ClusterIntegration|No zones matched your search') }} - </span> - </li> - <li v-for="result in results" :key="result.id"> - <button type="button" @click.prevent="setItem(result.name)">{{ result.name }}</button> - </li> - </ul> - </div> - <div class="dropdown-loading"><gl-loading-icon size="sm" /></div> - </div> - </div> - <span - v-if="hasErrors" - :class="{ - 'text-danger': hasErrors, - 'text-muted': !hasErrors, - }" - class="form-text" - > - {{ errorMessage }} - </span> - </div> -</template> diff --git a/app/assets/javascripts/create_cluster/gke_cluster/constants.js b/app/assets/javascripts/create_cluster/gke_cluster/constants.js deleted file mode 100644 index 2a1c0819916..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/constants.js +++ /dev/null @@ -1,11 +0,0 @@ -import { s__ } from '~/locale'; - -export const GCP_API_ERROR = s__( - 'ClusterIntegration|An error occurred when trying to contact the Google Cloud API. Please try again later.', -); -export const GCP_API_CLOUD_BILLING_ENDPOINT = - 'https://www.googleapis.com/discovery/v1/apis/cloudbilling/v1/rest'; -export const GCP_API_CLOUD_RESOURCE_MANAGER_ENDPOINT = - 'https://www.googleapis.com/discovery/v1/apis/cloudresourcemanager/v1/rest'; -export const GCP_API_COMPUTE_ENDPOINT = - 'https://www.googleapis.com/discovery/v1/apis/compute/v1/rest'; diff --git a/app/assets/javascripts/create_cluster/gke_cluster/gapi_loader.js b/app/assets/javascripts/create_cluster/gke_cluster/gapi_loader.js deleted file mode 100644 index b5f92fed8eb..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/gapi_loader.js +++ /dev/null @@ -1,24 +0,0 @@ -// This is a helper module to lazily import the google APIs for the GKE cluster -// integration without introducing an indirect global dependency on an -// initialized window.gapi object. -export default () => { - if (window.gapiPromise === undefined) { - // first time loading the module - window.gapiPromise = new Promise((resolve, reject) => { - // this callback is set as a query param to script.src URL - window.onGapiLoad = () => { - resolve(window.gapi); - }; - - const script = document.createElement('script'); - // do not use script.onload, because gapi continues to load after the initial script load - script.type = 'text/javascript'; - script.async = true; - script.src = 'https://apis.google.com/js/api.js?onload=onGapiLoad'; - script.onerror = reject; - document.head.appendChild(script); - }); - } - - return window.gapiPromise; -}; diff --git a/app/assets/javascripts/create_cluster/gke_cluster/index.js b/app/assets/javascripts/create_cluster/gke_cluster/index.js deleted file mode 100644 index 3a42b460e1c..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/index.js +++ /dev/null @@ -1,95 +0,0 @@ -import Vue from 'vue'; -import createFlash from '~/flash'; -import GkeMachineTypeDropdown from './components/gke_machine_type_dropdown.vue'; -import GkeProjectIdDropdown from './components/gke_project_id_dropdown.vue'; -import GkeSubmitButton from './components/gke_submit_button.vue'; -import GkeZoneDropdown from './components/gke_zone_dropdown.vue'; -import * as CONSTANTS from './constants'; -import gapiLoader from './gapi_loader'; - -import store from './store'; - -const mountComponent = (entryPoint, component, componentName, extraProps = {}) => { - const el = document.querySelector(entryPoint); - if (!el) return false; - - const hiddenInput = el.querySelector('input'); - - return new Vue({ - el, - store, - components: { - [componentName]: component, - }, - render: (createElement) => - createElement(componentName, { - props: { - fieldName: hiddenInput.getAttribute('name'), - fieldId: hiddenInput.getAttribute('id'), - defaultValue: hiddenInput.value, - ...extraProps, - }, - }), - }); -}; - -const mountGkeProjectIdDropdown = () => { - const entryPoint = '.js-gcp-project-id-dropdown-entry-point'; - const el = document.querySelector(entryPoint); - - mountComponent(entryPoint, GkeProjectIdDropdown, 'gke-project-id-dropdown', { - docsUrl: el.dataset.docsurl, - }); -}; - -const mountGkeZoneDropdown = () => { - mountComponent('.js-gcp-zone-dropdown-entry-point', GkeZoneDropdown, 'gke-zone-dropdown'); -}; - -const mountGkeMachineTypeDropdown = () => { - mountComponent( - '.js-gcp-machine-type-dropdown-entry-point', - GkeMachineTypeDropdown, - 'gke-machine-type-dropdown', - ); -}; - -const mountGkeSubmitButton = () => { - mountComponent('.js-gke-cluster-creation-submit-container', GkeSubmitButton, 'gke-submit-button'); -}; - -const gkeDropdownErrorHandler = () => { - createFlash({ - message: CONSTANTS.GCP_API_ERROR, - }); -}; - -const initializeGapiClient = (gapi) => () => { - const el = document.querySelector('.js-gke-cluster-creation'); - if (!el) return false; - - return gapi.client - .init({ - discoveryDocs: [ - CONSTANTS.GCP_API_CLOUD_BILLING_ENDPOINT, - CONSTANTS.GCP_API_CLOUD_RESOURCE_MANAGER_ENDPOINT, - CONSTANTS.GCP_API_COMPUTE_ENDPOINT, - ], - }) - .then(() => { - gapi.client.setToken({ access_token: el.dataset.token }); - - mountGkeProjectIdDropdown(); - mountGkeZoneDropdown(); - mountGkeMachineTypeDropdown(); - mountGkeSubmitButton(); - }) - .catch(gkeDropdownErrorHandler); -}; - -const initGkeDropdowns = () => - gapiLoader() - .then((gapi) => gapi.load('client', initializeGapiClient(gapi))) - .catch(gkeDropdownErrorHandler); - -export default initGkeDropdowns; diff --git a/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js b/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js deleted file mode 100644 index f4c35dafc22..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js +++ /dev/null @@ -1,99 +0,0 @@ -import gapiLoader from '../gapi_loader'; -import * as types from './mutation_types'; - -const gapiResourceListRequest = ({ resource, params, commit, mutation, payloadKey }) => - new Promise((resolve, reject) => { - const request = resource.list(params); - - return request.then( - (resp) => { - const { result } = resp; - - commit(mutation, result[payloadKey]); - - resolve(); - }, - (resp) => { - reject(resp); - }, - ); - }); - -export const setProject = ({ commit }, selectedProject) => { - commit(types.SET_PROJECT, selectedProject); -}; - -export const setZone = ({ commit }, selectedZone) => { - commit(types.SET_ZONE, selectedZone); -}; - -export const setMachineType = ({ commit }, selectedMachineType) => { - commit(types.SET_MACHINE_TYPE, selectedMachineType); -}; - -export const setIsValidatingProjectBilling = ({ commit }, isValidatingProjectBilling) => { - commit(types.SET_IS_VALIDATING_PROJECT_BILLING, isValidatingProjectBilling); -}; - -export const fetchProjects = ({ commit }) => - gapiLoader().then((gapi) => - gapiResourceListRequest({ - resource: gapi.client.cloudresourcemanager.projects, - params: {}, - commit, - mutation: types.SET_PROJECTS, - payloadKey: 'projects', - }), - ); - -export const validateProjectBilling = ({ dispatch, commit, state }) => - gapiLoader() - .then((gapi) => { - const request = gapi.client.cloudbilling.projects.getBillingInfo({ - name: `projects/${state.selectedProject.projectId}`, - }); - - commit(types.SET_ZONE, ''); - commit(types.SET_MACHINE_TYPE, ''); - - return request; - }) - .then( - (resp) => { - const { billingEnabled } = resp.result; - - commit(types.SET_PROJECT_BILLING_STATUS, Boolean(billingEnabled)); - dispatch('setIsValidatingProjectBilling', false); - }, - (errorResp) => { - dispatch('setIsValidatingProjectBilling', false); - return errorResp; - }, - ); - -export const fetchZones = ({ commit, state }) => - gapiLoader().then((gapi) => - gapiResourceListRequest({ - resource: gapi.client.compute.zones, - params: { - project: state.selectedProject.projectId, - }, - commit, - mutation: types.SET_ZONES, - payloadKey: 'items', - }), - ); - -export const fetchMachineTypes = ({ commit, state }) => - gapiLoader().then((gapi) => - gapiResourceListRequest({ - resource: gapi.client.compute.machineTypes, - params: { - project: state.selectedProject.projectId, - zone: state.selectedZone, - }, - commit, - mutation: types.SET_MACHINE_TYPES, - payloadKey: 'items', - }), - ); diff --git a/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js b/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js deleted file mode 100644 index 99f8393ffdb..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js +++ /dev/null @@ -1,5 +0,0 @@ -export const hasProject = (state) => Boolean(state.selectedProject.projectId); -export const hasZone = (state) => Boolean(state.selectedZone); -export const hasMachineType = (state) => Boolean(state.selectedMachineType); -export const hasValidData = (state, getters) => - Boolean(state.projectHasBillingEnabled) && getters.hasZone && getters.hasMachineType; diff --git a/app/assets/javascripts/create_cluster/gke_cluster/store/index.js b/app/assets/javascripts/create_cluster/gke_cluster/store/index.js deleted file mode 100644 index 5f72060633e..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/store/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import createState from './state'; - -Vue.use(Vuex); - -export const createStore = () => - new Vuex.Store({ - actions, - getters, - mutations, - state: createState(), - }); - -export default createStore(); diff --git a/app/assets/javascripts/create_cluster/gke_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/gke_cluster/store/mutation_types.js deleted file mode 100644 index 45a91efc2d9..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/store/mutation_types.js +++ /dev/null @@ -1,8 +0,0 @@ -export const SET_PROJECT = 'SET_PROJECT'; -export const SET_PROJECT_BILLING_STATUS = 'SET_PROJECT_BILLING_STATUS'; -export const SET_IS_VALIDATING_PROJECT_BILLING = 'SET_IS_VALIDATING_PROJECT_BILLING'; -export const SET_ZONE = 'SET_ZONE'; -export const SET_MACHINE_TYPE = 'SET_MACHINE_TYPE'; -export const SET_PROJECTS = 'SET_PROJECTS'; -export const SET_ZONES = 'SET_ZONES'; -export const SET_MACHINE_TYPES = 'SET_MACHINE_TYPES'; diff --git a/app/assets/javascripts/create_cluster/gke_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/gke_cluster/store/mutations.js deleted file mode 100644 index 88a2c1b630d..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/store/mutations.js +++ /dev/null @@ -1,28 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.SET_PROJECT](state, selectedProject) { - Object.assign(state, { selectedProject }); - }, - [types.SET_IS_VALIDATING_PROJECT_BILLING](state, isValidatingProjectBilling) { - Object.assign(state, { isValidatingProjectBilling }); - }, - [types.SET_PROJECT_BILLING_STATUS](state, projectHasBillingEnabled) { - Object.assign(state, { projectHasBillingEnabled }); - }, - [types.SET_ZONE](state, selectedZone) { - Object.assign(state, { selectedZone }); - }, - [types.SET_MACHINE_TYPE](state, selectedMachineType) { - Object.assign(state, { selectedMachineType }); - }, - [types.SET_PROJECTS](state, projects) { - Object.assign(state, { projects }); - }, - [types.SET_ZONES](state, zones) { - Object.assign(state, { zones }); - }, - [types.SET_MACHINE_TYPES](state, machineTypes) { - Object.assign(state, { machineTypes }); - }, -}; diff --git a/app/assets/javascripts/create_cluster/gke_cluster/store/state.js b/app/assets/javascripts/create_cluster/gke_cluster/store/state.js deleted file mode 100644 index 9f3c473d4bc..00000000000 --- a/app/assets/javascripts/create_cluster/gke_cluster/store/state.js +++ /dev/null @@ -1,13 +0,0 @@ -export default () => ({ - selectedProject: { - projectId: '', - name: '', - }, - selectedZone: '', - selectedMachineType: '', - isValidatingProjectBilling: null, - projectHasBillingEnabled: null, - projects: [], - zones: [], - machineTypes: [], -}); diff --git a/app/assets/javascripts/create_cluster/init_create_cluster.js b/app/assets/javascripts/create_cluster/init_create_cluster.js deleted file mode 100644 index d367d7ec333..00000000000 --- a/app/assets/javascripts/create_cluster/init_create_cluster.js +++ /dev/null @@ -1,35 +0,0 @@ -import PersistentUserCallout from '~/persistent_user_callout'; -import initGkeDropdowns from './gke_cluster'; -import initGkeNamespace from './gke_cluster_namespace'; - -const newClusterViews = [':clusters:new', ':clusters:create_gcp', ':clusters:create_user']; - -const isProjectLevelCluster = (page) => page.startsWith('project:clusters'); - -export default (document) => { - const { page } = document.body.dataset; - const isNewClusterView = newClusterViews.some((view) => page.endsWith(view)); - - if (!isNewClusterView) { - return; - } - - const callout = document.querySelector('.gcp-signup-offer'); - PersistentUserCallout.factory(callout); - - initGkeDropdowns(); - - if (isProjectLevelCluster(page)) { - initGkeNamespace(); - } - - import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster') - .then(({ default: initCreateEKSCluster }) => { - const el = document.querySelector('.js-create-eks-cluster-form-container'); - - if (el) { - initCreateEKSCluster(el); - } - }) - .catch(() => {}); -}; diff --git a/app/assets/javascripts/create_cluster/store/cluster_dropdown/actions.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/actions.js deleted file mode 100644 index 669b0dcc732..00000000000 --- a/app/assets/javascripts/create_cluster/store/cluster_dropdown/actions.js +++ /dev/null @@ -1,14 +0,0 @@ -import * as types from './mutation_types'; - -export default (fetchItems) => ({ - requestItems: ({ commit }) => commit(types.REQUEST_ITEMS), - receiveItemsSuccess: ({ commit }, payload) => commit(types.RECEIVE_ITEMS_SUCCESS, payload), - receiveItemsError: ({ commit }, payload) => commit(types.RECEIVE_ITEMS_ERROR, payload), - fetchItems: ({ dispatch }, payload) => { - dispatch('requestItems'); - - return fetchItems(payload) - .then((items) => dispatch('receiveItemsSuccess', { items })) - .catch((error) => dispatch('receiveItemsError', { error })); - }, -}); diff --git a/app/assets/javascripts/create_cluster/store/cluster_dropdown/getters.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/getters.js deleted file mode 100644 index e69de29bb2d..00000000000 --- a/app/assets/javascripts/create_cluster/store/cluster_dropdown/getters.js +++ /dev/null diff --git a/app/assets/javascripts/create_cluster/store/cluster_dropdown/index.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/index.js deleted file mode 100644 index de8cc44fa7c..00000000000 --- a/app/assets/javascripts/create_cluster/store/cluster_dropdown/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import state from './state'; - -const createStore = ({ fetchFn, initialState }) => ({ - actions: actions(fetchFn), - getters, - mutations, - state: Object.assign(state(), initialState || {}), -}); - -export default createStore; diff --git a/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutation_types.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutation_types.js deleted file mode 100644 index 48959a73924..00000000000 --- a/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutation_types.js +++ /dev/null @@ -1,3 +0,0 @@ -export const REQUEST_ITEMS = 'REQUEST_ITEMS'; -export const RECEIVE_ITEMS_SUCCESS = 'REQUEST_ITEMS_SUCCESS'; -export const RECEIVE_ITEMS_ERROR = 'RECEIVE_ITEMS_ERROR'; diff --git a/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutations.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutations.js deleted file mode 100644 index d09689f1f6c..00000000000 --- a/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutations.js +++ /dev/null @@ -1,16 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.REQUEST_ITEMS](state) { - state.isLoadingItems = true; - state.loadingItemsError = null; - }, - [types.RECEIVE_ITEMS_SUCCESS](state, { items }) { - state.isLoadingItems = false; - state.items = items; - }, - [types.RECEIVE_ITEMS_ERROR](state, { error }) { - state.isLoadingItems = false; - state.loadingItemsError = error; - }, -}; diff --git a/app/assets/javascripts/create_cluster/store/cluster_dropdown/state.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/state.js deleted file mode 100644 index b949a24216e..00000000000 --- a/app/assets/javascripts/create_cluster/store/cluster_dropdown/state.js +++ /dev/null @@ -1,5 +0,0 @@ -export default () => ({ - isLoadingItems: false, - items: [], - loadingItemsError: null, -}); diff --git a/app/assets/javascripts/crm/components/form.vue b/app/assets/javascripts/crm/components/form.vue index 4f94898ff63..72def54aedf 100644 --- a/app/assets/javascripts/crm/components/form.vue +++ b/app/assets/javascripts/crm/components/form.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui'; import { get as getPropValueByPath, isEmpty } from 'lodash'; import { produce } from 'immer'; import { MountingPortal } from 'portal-vue'; @@ -28,6 +28,7 @@ export default { GlDrawer, GlFormGroup, GlFormInput, + GlFormSelect, MountingPortal, }, props: { @@ -136,15 +137,25 @@ export default { methods: { setInitialModel() { const existingModel = this.records.find(({ id }) => id === this.existingId); + const noModel = !this.isEditMode || !existingModel; this.model = this.fields.reduce( (map, field) => Object.assign(map, { - [field.name]: !this.isEditMode || !existingModel ? null : existingModel[field.name], + [field.name]: noModel ? null : this.extractValue(existingModel, field.name), }), {}, ); }, + extractValue(existingModel, fieldName) { + const value = existingModel[fieldName]; + if (value != null) return value; + + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + if (!fieldName.endsWith('Id')) return null; + + return existingModel[fieldName.slice(0, -2)]?.id; + }, formatValue(model, field) { if (!isEmpty(model[field.name]) && field.input?.type === 'number') { return parseFloat(model[field.name]); @@ -216,6 +227,15 @@ export default { return data[keys[0]]; }, + getDrawerHeaderHeight() { + const wrapperEl = document.querySelector('.content-wrapper'); + + if (wrapperEl) { + return `${wrapperEl.offsetTop}px`; + } + + return ''; + }, }, MSG_CANCEL, INDEX_ROUTE_NAME, @@ -224,7 +244,12 @@ export default { <template> <mounting-portal v-if="!loading" mount-to="#js-crm-form-portal" append> - <gl-drawer class="gl-drawer-responsive gl-absolute" :open="drawerOpen" @close="close(false)"> + <gl-drawer + :header-height="getDrawerHeaderHeight()" + class="gl-drawer-responsive" + :open="drawerOpen" + @close="close(false)" + > <template #title> <h3>{{ title }}</h3> </template> @@ -242,7 +267,13 @@ export default { :label="getFieldLabel(field)" :label-for="field.name" > - <gl-form-input :id="field.name" v-bind="field.input" v-model="model[field.name]" /> + <gl-form-select + v-if="field.values" + :id="field.name" + v-model="model[field.name]" + :options="field.values" + /> + <gl-form-input v-else :id="field.name" v-bind="field.input" v-model="model[field.name]" /> </gl-form-group> <span class="gl-float-right"> <gl-button data-testid="cancel-button" @click="close(false)"> diff --git a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue index 58eaabfbb7f..f114ffedfe6 100644 --- a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue +++ b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue @@ -3,6 +3,7 @@ import { s__, __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_CRM_CONTACT, TYPE_GROUP } from '~/graphql_shared/constants'; import ContactForm from '../../components/form.vue'; +import getGroupOrganizationsQuery from '../../organizations/components/graphql/get_group_organizations.query.graphql'; import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql'; import createContactMutation from './graphql/create_contact.mutation.graphql'; import updateContactMutation from './graphql/update_contact.mutation.graphql'; @@ -19,6 +20,26 @@ export default { default: false, }, }, + data() { + return { + organizations: [], + }; + }, + apollo: { + organizations: { + query() { + return getGroupOrganizationsQuery; + }, + variables() { + return { + groupFullPath: this.groupFullPath, + }; + }, + update(data) { + return this.extractOrganizations(data); + }, + }, + }, computed: { contactGraphQLId() { if (!this.isEditMode) return null; @@ -52,14 +73,35 @@ export default { additionalCreateParams() { return { groupId: this.groupGraphQLId }; }, + fields() { + return [ + { name: 'firstName', label: __('First name'), required: true }, + { name: 'lastName', label: __('Last name'), required: true }, + { name: 'email', label: __('Email'), required: true }, + { name: 'phone', label: __('Phone') }, + { + name: 'organizationId', + label: s__('Crm|Organization'), + values: this.organizationSelectValues, + }, + { name: 'description', label: __('Description') }, + ]; + }, + organizationSelectValues() { + const values = this.organizations.map((o) => { + return { value: o.id, text: o.name }; + }); + + values.unshift({ value: null, text: s__('Crm|No organization') }); + return values; + }, + }, + methods: { + extractOrganizations(data) { + const organizations = data?.group?.organizations?.nodes || []; + return organizations.slice().sort((a, b) => a.name.localeCompare(b.name)); + }, }, - fields: [ - { name: 'firstName', label: __('First name'), required: true }, - { name: 'lastName', label: __('Last name'), required: true }, - { name: 'email', label: __('Email'), required: true }, - { name: 'phone', label: __('Phone') }, - { name: 'description', label: __('Description') }, - ], }; </script> @@ -71,7 +113,7 @@ export default { :mutation="mutation" :additional-create-params="additionalCreateParams" :existing-id="contactGraphQLId" - :fields="$options.fields" + :fields="fields" :title="title" :success-message="successMessage" /> diff --git a/app/assets/javascripts/crm/contacts/components/contacts_root.vue b/app/assets/javascripts/crm/contacts/components/contacts_root.vue index 17be3800256..9d6f34c73b7 100644 --- a/app/assets/javascripts/crm/contacts/components/contacts_root.vue +++ b/app/assets/javascripts/crm/contacts/components/contacts_root.vue @@ -55,7 +55,7 @@ export default { return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName)); }, getIssuesPath(path, value) { - return `${path}?scope=all&state=opened&crm_contact_id=${value}`; + return `${path}?crm_contact_id=${value}`; }, getEditRoute(id) { return { name: this.$options.EDIT_ROUTE_NAME, params: { id } }; diff --git a/app/assets/javascripts/crm/organizations/components/organizations_root.vue b/app/assets/javascripts/crm/organizations/components/organizations_root.vue index 522e29eb2af..a165dd68603 100644 --- a/app/assets/javascripts/crm/organizations/components/organizations_root.vue +++ b/app/assets/javascripts/crm/organizations/components/organizations_root.vue @@ -55,7 +55,7 @@ export default { return organizations.slice().sort((a, b) => a.name.localeCompare(b.name)); }, getIssuesPath(path, value) { - return `${path}?scope=all&state=opened&crm_organization_id=${value}`; + return `${path}?crm_organization_id=${value}`; }, getEditRoute(id) { return { name: this.$options.EDIT_ROUTE_NAME, params: { id } }; diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue index 3d7a34581b3..1883030e51f 100644 --- a/app/assets/javascripts/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/cycle_analytics/components/base.vue @@ -3,6 +3,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; import { getCookie, setCookie } from '~/lib/utils/common_utils'; import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue'; +import { VSA_METRICS_GROUPS } from '~/analytics/shared/constants'; import { toYmd } from '~/analytics/shared/utils'; import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; import StageTable from '~/cycle_analytics/components/stage_table.vue'; @@ -150,34 +151,36 @@ export default { pageTitle: __('Value Stream Analytics'), recentActivity: __('Recent Project Activity'), }, + VSA_METRICS_GROUPS, }; </script> <template> <div> <h3>{{ $options.i18n.pageTitle }}</h3> + <value-stream-filters + :group-id="endpoints.groupId" + :group-path="endpoints.groupPath" + :has-project-filter="false" + :start-date="createdAfter" + :end-date="createdBefore" + @setDateRange="onSetDateRange" + /> <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row"> <path-navigation v-if="displayPathNavigation" data-testid="vsa-path-navigation" - class="gl-w-full gl-pb-2" + class="gl-w-full gl-mt-4" :loading="isLoading || isLoadingStage" :stages="pathNavigationData" :selected-stage="selectedStage" @selected="onSelectStage" /> </div> - <value-stream-filters - :group-id="endpoints.groupId" - :group-path="endpoints.groupPath" - :has-project-filter="false" - :start-date="createdAfter" - :end-date="createdBefore" - @setDateRange="onSetDateRange" - /> <value-stream-metrics :request-path="endpoints.fullPath" :request-params="filterParams" :requests="metricsRequests" + :group-by="$options.VSA_METRICS_GROUPS" /> <gl-loading-icon v-if="isLoading" size="lg" /> <stage-table diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue index 6a45969fd1a..e4236968efc 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue @@ -191,9 +191,7 @@ export default { /> <gl-table v-else - head-variant="white" stacked="lg" - thead-class="border-bottom" show-empty :sort-by.sync="sort" :sort-direction.sync="direction" diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue index 66bccf19496..f686cd0db95 100644 --- a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue +++ b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue @@ -1,21 +1,13 @@ <script> -import { GlIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlTooltipDirective } from '@gitlab/ui'; import DateRange from '~/analytics/shared/components/daterange.vue'; import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; import { DATE_RANGE_LIMIT, PROJECTS_PER_PAGE } from '~/analytics/shared/constants'; import FilterBar from './filter_bar.vue'; -export const AGGREGATION_TOGGLE_LABEL = s__('CycleAnalytics|Filter by stop date'); -export const AGGREGATION_DESCRIPTION = s__( - 'CycleAnalytics|When enabled, the results show items with a stop event within the date range. When disabled, the results show items with a start event within the date range.', -); - export default { name: 'ValueStreamFilters', components: { - GlIcon, - GlToggle, DateRange, ProjectsDropdownFilter, FilterBar, @@ -57,21 +49,6 @@ export default { required: false, default: null, }, - canToggleAggregation: { - type: Boolean, - required: false, - default: false, - }, - isAggregationEnabled: { - type: Boolean, - required: false, - default: false, - }, - isUpdatingAggregationData: { - type: Boolean, - required: false, - default: false, - }, }, computed: { projectsQueryParams() { @@ -81,19 +58,8 @@ export default { }; }, }, - methods: { - onUpdateAggregation(ev) { - if (!this.isUpdatingAggregationData) { - this.$emit('toggleAggregation', ev); - } - }, - }, multiProjectSelect: true, maxDateRange: DATE_RANGE_LIMIT, - i18n: { - AGGREGATION_TOGGLE_LABEL, - AGGREGATION_DESCRIPTION, - }, }; </script> <template> @@ -123,27 +89,6 @@ export default { /> </div> <div class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row"> - <div - v-if="canToggleAggregation" - class="gl-display-flex gl-text-align-center gl-my-2 gl-lg-mt-0 gl-lg-mb-0 gl-mr-5" - > - <gl-toggle - class="gl-flex-direction-row" - :value="isAggregationEnabled" - :label="$options.i18n.AGGREGATION_TOGGLE_LABEL" - :disabled="isUpdatingAggregationData" - label-position="left" - @change="onUpdateAggregation" - > - <template #label> - {{ $options.i18n.AGGREGATION_TOGGLE_LABEL }} <gl-icon - v-gl-tooltip.hover - :title="$options.i18n.AGGREGATION_DESCRIPTION" - name="information-o" - /> - </template> - </gl-toggle> - </div> <date-range v-if="hasDateRangeFilter" :start-date="startDate" diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue index 7acb5549273..814a4b672a2 100644 --- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue @@ -137,7 +137,7 @@ export default { v-model="freezeStartCron" class="gl-font-monospace!" data-qa-selector="deploy_freeze_start_field" - :placeholder="this.$options.translations.cronPlaceholder" + :placeholder="$options.translations.cronPlaceholder" :state="freezeStartCronState" trim /> @@ -154,7 +154,7 @@ export default { v-model="freezeEndCron" class="gl-font-monospace!" data-qa-selector="deploy_freeze_end_field" - :placeholder="this.$options.translations.cronPlaceholder" + :placeholder="$options.translations.cronPlaceholder" :state="freezeEndCronState" trim /> diff --git a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql index 124f12ef018..c235e7fbf3d 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql @@ -2,7 +2,7 @@ #import "../fragments/discussion_resolved_status.fragment.graphql" #import "../fragments/design_todo_item.fragment.graphql" -mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) { +mutation toggleResolveDiscussion($id: DiscussionID!, $resolve: Boolean!) { discussionToggleResolve(input: { id: $id, resolve: $resolve }) { discussion { id diff --git a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql index 34d683ac1ee..3200327e03d 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql @@ -3,7 +3,6 @@ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) { designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) { - # eslint-disable-next-line @graphql-eslint/require-id-when-available designs { ...DesignItem versions { diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql index a5394457f73..730467c33f6 100644 --- a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql @@ -13,7 +13,6 @@ query getDesign( id designCollection { designs(atVersion: $atVersion, filenames: $filenames) { - # eslint-disable-next-line @graphql-eslint/require-id-when-available nodes { ...DesignItem issue { diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 837320b9423..2b395921ee1 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -349,7 +349,9 @@ export default { class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative" > <design-destroyer - :filenames="[design.filename]" + :filenames="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ + design.filename, + ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" :project-path="projectPath" :iid="issueIid" @done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })" diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index c86f2c8451c..c3436159cea 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -17,7 +17,6 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { parseBoolean } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -75,7 +74,6 @@ export default { PanelResizer, GlPagination, GlSprintf, - MrWidgetHowToMergeModal, GlAlert, }, mixins: [glFeatureFlagsMixin()], @@ -373,7 +371,7 @@ export default { events.push(TRACKING_MULTIPLE_FILES_MODE); } - queueRedisHllEvents(events); + queueRedisHllEvents(events, { verifyCap: true }); this.subscribeToVirtualScrollingEvents(); }, @@ -738,15 +736,6 @@ export default { /> </div> </div> - <mr-widget-how-to-merge-modal - :is-fork="isForked" - :can-merge="canMerge" - :source-branch="branchName" - :source-project-path="sourceProjectFullPath" - :target-branch="targetBranchName" - :source-project-default-url="sourceProjectDefaultUrl" - :reviewing-docs-path="$options.howToMergeDocsPath" - /> </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue index 6c5973b7c28..fd219a7d00f 100644 --- a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue +++ b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue @@ -24,7 +24,12 @@ export default { </script> <template> - <gl-dropdown :text="selectedVersionName" data-qa-selector="dropdown_content"> + <gl-dropdown + :text="selectedVersionName" + data-qa-selector="dropdown_content" + size="small" + category="tertiary" + > <template v-for="version in versions"> <gl-dropdown-divider v-if="version.addDivider" :key="version.id" /> <gl-dropdown-item diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 4dfd672f99b..8a5325cf218 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -79,7 +79,7 @@ export default { </script> <template> - <div class="mr-version-controls border-top"> + <div class="mr-version-controls"> <div class="mr-version-menus-container content-block"> <gl-button v-if="hasChanges" diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index b4bffdcb07f..1eba12a3ae9 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -156,7 +156,7 @@ export default { :link-href="author.path" :img-src="author.avatar_url" :img-alt="author.name" - :img-size="40" + :img-size="48" class="d-none d-sm-block new-comment" /> <diff-discussions diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index 4c7b8e8f667..4e7dc578193 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -1,42 +1,33 @@ <script> -import { GlIcon } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; +import { GlTooltipDirective, GlSafeHtmlDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { mapActions } from 'vuex'; import createFlash from '~/flash'; import { s__, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { UNFOLD_COUNT, INLINE_DIFF_VIEW_TYPE, INLINE_DIFF_LINES_KEY } from '../constants'; +import { UNFOLD_COUNT, INLINE_DIFF_LINES_KEY } from '../constants'; import * as utils from '../store/utils'; const EXPAND_ALL = 0; const EXPAND_UP = 1; const EXPAND_DOWN = 2; -const lineNumberByViewType = (viewType, diffLine) => { - const numberGetters = { - [INLINE_DIFF_VIEW_TYPE]: (line) => line?.new_line, - }; - const numberGetter = numberGetters[viewType]; - return numberGetter && numberGetter(diffLine); -}; - -const i18n = { - showMore: sprintf(s__('Diffs|Show %{unfoldCount} lines'), { unfoldCount: UNFOLD_COUNT }), - showAll: s__('Diffs|Show all unchanged lines'), -}; - export default { - i18n, + i18n: { + showMore: sprintf(s__('Diffs|Show %{unfoldCount} lines'), { unfoldCount: UNFOLD_COUNT }), + showAll: s__('Diffs|Show all unchanged lines'), + }, components: { GlIcon, + GlLoadingIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml: GlSafeHtmlDirective, }, mixins: [glFeatureFlagsMixin()], props: { - fileHash: { - type: String, - required: true, - }, - contextLinesPath: { - type: String, + file: { + type: Object, required: true, }, line: { @@ -53,34 +44,45 @@ export default { required: false, default: false, }, + inline: { + type: Boolean, + required: true, + }, + lineCountBetween: { + type: Number, + required: false, + default: -1, + }, + }, + data() { + return { loading: { up: false, down: false, all: false } }; }, computed: { - ...mapState({ - diffFiles: (state) => state.diffs.diffFiles, - }), canExpandUp() { return !this.isBottom; }, canExpandDown() { return this.isBottom || !this.isTop; }, - }, - created() { - this.EXPAND_DOWN = EXPAND_DOWN; - this.EXPAND_UP = EXPAND_UP; + isLineCountSmall() { + return this.lineCountBetween >= 20 || this.lineCountBetween === -1; + }, + showExpandDown() { + return this.canExpandDown && this.isLineCountSmall; + }, + showExpandUp() { + return this.canExpandUp && this.isLineCountSmall; + }, }, methods: { ...mapActions('diffs', ['loadMoreLines']), getPrevLineNumber(oldLineNumber, newLineNumber) { - const diffFile = utils.findDiffFile(this.diffFiles, this.fileHash); - const index = utils.getPreviousLineIndex(INLINE_DIFF_VIEW_TYPE, diffFile, { + const index = utils.getPreviousLineIndex(this.file, { oldLineNumber, newLineNumber, }); - return ( - lineNumberByViewType(INLINE_DIFF_VIEW_TYPE, diffFile[INLINE_DIFF_LINES_KEY][index - 2]) || 0 - ); + return this.file[INLINE_DIFF_LINES_KEY][index - 2]?.new_line || 0; }, callLoadMoreLines( endpoint, @@ -99,6 +101,9 @@ export default { message: s__('Diffs|Something went wrong while fetching diff lines.'), }); this.isRequesting = false; + }) + .finally(() => { + this.loading = { up: false, down: false, all: false }; }); }, handleExpandLines(type = EXPAND_ALL) { @@ -107,25 +112,26 @@ export default { } this.isRequesting = true; - const endpoint = this.contextLinesPath; - const { fileHash } = this; - const view = INLINE_DIFF_VIEW_TYPE; + const endpoint = this.file.context_lines_path; const oldLineNumber = this.line.meta_data.old_pos || 0; const newLineNumber = this.line.meta_data.new_pos || 0; const offset = newLineNumber - oldLineNumber; - const expandOptions = { endpoint, fileHash, view, oldLineNumber, newLineNumber, offset }; + const expandOptions = { endpoint, oldLineNumber, newLineNumber, offset }; if (type === EXPAND_UP) { + this.loading.up = true; this.handleExpandUpLines(expandOptions); } else if (type === EXPAND_DOWN) { + this.loading.down = true; this.handleExpandDownLines(expandOptions); } else { + this.loading.all = true; this.handleExpandAllLines(expandOptions); } }, handleExpandUpLines(expandOptions) { - const { endpoint, fileHash, view, oldLineNumber, newLineNumber, offset } = expandOptions; + const { endpoint, oldLineNumber, newLineNumber, offset } = expandOptions; const bottom = this.isBottom; const lineNumber = newLineNumber - 1; @@ -139,15 +145,13 @@ export default { unfold = false; } - const params = { since, to, bottom, offset, unfold, view }; + const params = { since, to, bottom, offset, unfold }; const lineNumbers = { oldLineNumber, newLineNumber }; - this.callLoadMoreLines(endpoint, params, lineNumbers, fileHash); + this.callLoadMoreLines(endpoint, params, lineNumbers, this.file.file_hash); }, handleExpandDownLines(expandOptions) { const { endpoint, - fileHash, - view, oldLineNumber: metaOldPos, newLineNumber: metaNewPos, offset, @@ -183,19 +187,19 @@ export default { } } - const params = { since, to, bottom, offset, unfold, view }; + const params = { since, to, bottom, offset, unfold }; const lineNumbers = { oldLineNumber, newLineNumber }; this.callLoadMoreLines( endpoint, params, lineNumbers, - fileHash, + this.file.file_hash, isExpandDown, nextLineNumbers, ); }, handleExpandAllLines(expandOptions) { - const { endpoint, fileHash, view, oldLineNumber, newLineNumber, offset } = expandOptions; + const { endpoint, oldLineNumber, newLineNumber, offset } = expandOptions; const bottom = this.isBottom; const unfold = false; let since; @@ -213,21 +217,71 @@ export default { to = newLineNumber - 1; } - const params = { since, to, bottom, offset, unfold, view }; + const params = { since, to, bottom, offset, unfold }; const lineNumbers = { oldLineNumber, newLineNumber }; - this.callLoadMoreLines(endpoint, params, lineNumbers, fileHash); + this.callLoadMoreLines(endpoint, params, lineNumbers, this.file.file_hash); }, }, + EXPAND_DOWN, + EXPAND_UP, }; </script> <template> - <div class="content js-line-expansion-content"> + <div + v-if="glFeatures.updatedDiffExpansionButtons" + class="diff-grid-row diff-grid-row-full diff-tr line_holder match expansion" + > + <div :class="{ parallel: !inline }" class="diff-grid-left diff-grid-2-col left-side"> + <div + class="diff-td diff-line-num gl-text-center! gl-p-0! gl-w-full! gl-display-flex gl-flex-direction-column" + > + <button + v-if="showExpandDown" + v-gl-tooltip.left + :title="s__('Diffs|Next 20 lines')" + type="button" + class="js-unfold-down gl-rounded-0 gl-border-0 diff-line-expand-button" + @click="handleExpandLines($options.EXPAND_DOWN)" + > + <gl-loading-icon v-if="loading.down" size="sm" color="dark" inline /> + <gl-icon v-else name="expand-down" /> + </button> + <button + v-if="lineCountBetween !== -1 && lineCountBetween < 20" + v-gl-tooltip.left + :title="s__('Diffs|Expand all lines')" + type="button" + class="js-unfold-all gl-rounded-0 gl-border-0 diff-line-expand-button" + @click="handleExpandLines()" + > + <gl-loading-icon v-if="loading.all" size="sm" color="dark" inline /> + <gl-icon v-else name="expand" /> + </button> + <button + v-if="showExpandUp" + v-gl-tooltip.left + :title="s__('Diffs|Previous 20 lines')" + type="button" + class="js-unfold gl-rounded-0 gl-border-0 diff-line-expand-button" + @click="handleExpandLines($options.EXPAND_UP)" + > + <gl-loading-icon v-if="loading.up" size="sm" color="dark" inline /> + <gl-icon v-else name="expand-up" /> + </button> + </div> + <div + v-safe-html="line.rich_text" + class="gl-display-flex! gl-flex-direction-column gl-justify-content-center diff-td line_content left-side gl-white-space-normal!" + ></div> + </div> + </div> + <div v-else class="content js-line-expansion-content"> <button type="button" :disabled="!canExpandDown" class="js-unfold-down gl-mx-2 gl-py-4 gl-cursor-pointer" - @click="handleExpandLines(EXPAND_DOWN)" + @click="handleExpandLines($options.EXPAND_DOWN)" > <gl-icon :size="12" name="expand-down" /> <span>{{ $options.i18n.showMore }}</span> @@ -244,7 +298,7 @@ export default { type="button" :disabled="!canExpandUp" class="js-unfold gl-mx-2 gl-py-4 gl-cursor-pointer" - @click="handleExpandLines(EXPAND_UP)" + @click="handleExpandLines($options.EXPAND_UP)" > <gl-icon :size="12" name="expand-up" /> <span>{{ $options.i18n.showMore }}</span> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index d8f27a967df..0b82be7140c 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -5,7 +5,6 @@ import { GlSafeHtmlDirective as SafeHtml, GlSprintf, GlAlert, - GlModalDirective, } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; @@ -42,7 +41,6 @@ export default { }, directives: { SafeHtml, - GlModalDirective, }, mixins: [glFeatureFlagsMixin(), IdState({ idProp: (vm) => vm.file.file_hash })], props: { @@ -360,10 +358,12 @@ export default { class="js-file-fork-suggestion-section file-fork-suggestion" > <span v-safe-html="forkMessage" class="file-fork-suggestion-note"></span> - <a + <gl-button :href="file.fork_path" - class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success" - >{{ $options.i18n.fork }}</a + class="js-fork-suggestion-button" + category="secondary" + variant="confirm" + >{{ $options.i18n.fork }}</gl-button > <button class="js-cancel-fork-suggestion-button btn btn-grouped" @@ -379,6 +379,53 @@ export default { :class="hasBodyClasses.contentByHash" data-testid="content-area" > + <gl-alert + v-if="!showLoadingIcon && file.conflict_type" + variant="danger" + :dismissible="false" + data-testid="conflictsAlert" + > + {{ $options.CONFLICT_TEXT[file.conflict_type] }} + <template v-if="!canMerge"> + {{ __('Ask someone with write access to resolve it.') }} + </template> + <gl-sprintf + v-else-if="conflictResolutionPath" + :message=" + __( + 'You can %{gitlabLinkStart}resolve conflicts on GitLab%{gitlabLinkEnd} or %{resolveLocallyStart}resolve it locally%{resolveLocallyEnd}.', + ) + " + > + <template #gitlabLink="{ content }"> + <gl-button + :href="conflictResolutionPath" + variant="link" + class="gl-vertical-align-text-bottom" + >{{ content }}</gl-button + > + </template> + <template #resolveLocally="{ content }"> + <gl-button + variant="link" + class="gl-vertical-align-text-bottom js-check-out-modal-trigger" + >{{ content }}</gl-button + > + </template> + </gl-sprintf> + <gl-sprintf + v-else + :message="__('You can %{resolveLocallyStart}resolve it locally%{resolveLocallyEnd}.')" + > + <template #resolveLocally="{ content }"> + <gl-button + variant="link" + class="gl-vertical-align-text-bottom js-check-out-modal-trigger" + >{{ content }}</gl-button + > + </template> + </gl-sprintf> + </gl-alert> <gl-loading-icon v-if="showLoadingIcon" size="sm" @@ -402,55 +449,6 @@ export default { <div v-else v-safe-html="errorMessage" class="nothing-here-block"></div> </div> <template v-else> - <gl-alert - v-if="file.conflict_type" - variant="danger" - :dismissible="false" - data-testid="conflictsAlert" - > - {{ $options.CONFLICT_TEXT[file.conflict_type] }} - <template v-if="!canMerge"> - {{ __('Ask someone with write access to resolve it.') }} - </template> - <gl-sprintf - v-else-if="conflictResolutionPath" - :message=" - __( - 'You can %{gitlabLinkStart}resolve conflicts on GitLab%{gitlabLinkEnd} or %{resolveLocallyStart}resolve it locally%{resolveLocallyEnd}.', - ) - " - > - <template #gitlabLink="{ content }"> - <gl-button - :href="conflictResolutionPath" - variant="link" - class="gl-vertical-align-text-bottom" - >{{ content }}</gl-button - > - </template> - <template #resolveLocally="{ content }"> - <gl-button - v-gl-modal-directive="'modal-merge-info'" - variant="link" - class="gl-vertical-align-text-bottom" - >{{ content }}</gl-button - > - </template> - </gl-sprintf> - <gl-sprintf - v-else - :message="__('You can %{resolveLocallyStart}resolve it locally%{resolveLocallyEnd}.')" - > - <template #resolveLocally="{ content }"> - <gl-button - v-gl-modal-directive="'modal-merge-info'" - variant="link" - class="gl-vertical-align-text-bottom" - >{{ content }}</gl-button - > - </template> - </gl-sprintf> - </gl-alert> <div v-if="showWarning" class="collapsed-file-warning gl-p-7 gl-bg-orange-50 gl-text-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 8cdbd2b7dbc..a75262ee303 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -302,6 +302,7 @@ export default { @click="handleFileNameClick" > <file-icon + v-if="!glFeatures.removeDiffHeaderIcons" :file-name="filePath" :size="16" aria-hidden="true" @@ -394,6 +395,7 @@ export default { <gl-dropdown v-gl-tooltip.hover.focus="$options.i18n.optionsDropdownTitle" size="small" + category="tertiary" right toggle-class="btn-icon js-diff-more-actions" class="gl-pt-0!" @@ -402,7 +404,7 @@ export default { @hidden="setMoreActionsShown(false)" > <template #button-content> - <gl-icon name="ellipsis_v" class="mr-0" :size="12" /> + <gl-icon name="ellipsis_v" class="mr-0" /> <span class="sr-only">{{ $options.i18n.optionsDropdownTitle }}</span> </template> <gl-dropdown-item diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 7a30740e31b..a2f0e2c2653 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -179,7 +179,10 @@ export default { if (shouldConfirm && isDirty) { const msg = s__('Notes|Are you sure you want to cancel creating this comment?'); - const confirmed = await confirmAction(msg); + const confirmed = await confirmAction(msg, { + primaryBtnText: __('Discard changes'), + cancelBtnText: __('Continue editing'), + }); if (!confirmed) { return; diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 4893803a3b6..1b07b00d725 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -160,7 +160,13 @@ export default { <!-- eslint-disable-next-line vue/no-deprecated-functional-template --> <template functional> - <div :class="$options.classNameMap(props)" class="diff-grid-row diff-tr line_holder"> + <div + :class="[ + $options.classNameMap(props), + { expansion: props.line.left && props.line.left.type === 'expanded' }, + ]" + class="diff-grid-row diff-tr line_holder" + > <div :id="props.line.left && props.line.left.line_code" data-testid="left-side" diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js index 99999445c43..f610ac979ca 100644 --- a/app/assets/javascripts/diffs/components/diff_row_utils.js +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -10,6 +10,7 @@ import { CONFLICT_MARKER_THEIR, CONFLICT_THEIR, CONFLICT_OUR, + EXPANDED_LINE_TYPE, } from '../constants'; export const isHighlighted = (highlightedRow, line, isCommented) => { @@ -118,10 +119,12 @@ export const mapParallel = (content) => (line) => { if (right) { right = { ...right, - renderDiscussion: Boolean(hasExpandedDiscussionOnRight && right.type), + renderDiscussion: Boolean( + hasExpandedDiscussionOnRight && right.type && right.type !== EXPANDED_LINE_TYPE, + ), hasDraft: content.hasParallelDraftRight(content.diffFile.file_hash, line), lineDraft: content.draftForLine(content.diffFile.file_hash, line, 'right'), - hasCommentForm: Boolean(right.hasForm && right.type), + hasCommentForm: Boolean(right.hasForm && right.type && right.type !== EXPANDED_LINE_TYPE), emptyCellClassMap: { conflict_their: line.left?.type === CONFLICT_OUR }, addCommentTooltip: addCommentTooltip(line.right), }; diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index f46b0a538f1..529f8e0a2f9 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -141,6 +141,18 @@ export default { table.classList.add(`${lineClass}-selected`); } }, + getCountBetweenIndex(index) { + if (index === 0) { + return -1; + } else if (!this.diffLines[index + 1]) { + return -1; + } + + return ( + Number(this.diffLines[index + 1].left.new_line) - + Number(this.diffLines[index - 1].left.new_line) + ); + }, }, userColorScheme: window.gon.user_color_scheme, }; @@ -158,38 +170,51 @@ export default { > <template v-for="(line, index) in diffLines"> <template v-if="line.isMatchLineLeft || line.isMatchLineRight"> - <div :key="`expand-${index}`" class="diff-tr line_expansion match"> - <div class="diff-td text-center gl-font-regular"> - <diff-expansion-cell - :file-hash="diffFile.file_hash" - :context-lines-path="diffFile.context_lines_path" - :line="line.left" - :is-top="index === 0" - :is-bottom="index + 1 === diffLinesLength" - /> + <diff-expansion-cell + v-if="glFeatures.updatedDiffExpansionButtons" + :key="`expand-${index}`" + :file="diffFile" + :line="line.left" + :is-top="index === 0" + :is-bottom="index + 1 === diffLinesLength" + :inline="inline" + :line-count-between="getCountBetweenIndex(index)" + /> + <template v-else> + <div :key="`expand-${index}`" class="diff-tr line_expansion old-line_expansion match"> + <div class="diff-td text-center gl-font-regular"> + <diff-expansion-cell + :file="diffFile" + :context-lines-path="diffFile.context_lines_path" + :line="line.left" + :is-top="index === 0" + :is-bottom="index + 1 === diffLinesLength" + :inline="inline" + /> + </div> </div> - </div> - <div - v-if="line.left.rich_text" - :key="`expand-definition-${index}`" - class="diff-grid-row diff-tr line_holder match" - > - <div class="diff-grid-left diff-grid-3-col left-side"> - <div class="diff-td diff-line-num"></div> - <div v-if="inline" class="diff-td diff-line-num"></div> - <div - v-safe-html="line.left.rich_text" - class="diff-td line_content left-side gl-white-space-normal!" - ></div> + <div + v-if="line.left.rich_text" + :key="`expand-definition-${index}`" + class="diff-grid-row diff-tr line_holder match" + > + <div class="diff-grid-left diff-grid-3-col left-side"> + <div class="diff-td diff-line-num"></div> + <div v-if="inline" class="diff-td diff-line-num"></div> + <div + v-safe-html="line.left.rich_text" + class="diff-td line_content left-side gl-white-space-normal!" + ></div> + </div> + <div v-if="!inline" class="diff-grid-right diff-grid-3-col right-side"> + <div class="diff-td diff-line-num"></div> + <div + v-safe-html="line.left.rich_text" + class="diff-td line_content right-side gl-white-space-normal!" + ></div> + </div> </div> - <div v-if="!inline" class="diff-grid-right diff-grid-3-col right-side"> - <div class="diff-td diff-line-num"></div> - <div - v-safe-html="line.left.rich_text" - class="diff-td line_content right-side gl-white-space-normal!" - ></div> - </div> - </div> + </template> </template> <diff-row v-if="!line.isMatchLineLeft && !line.isMatchLineRight" diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue index b9962682848..f6a8c679f3b 100644 --- a/app/assets/javascripts/diffs/components/hidden_files_warning.vue +++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue @@ -51,7 +51,7 @@ export default { __( 'To preserve performance only %{strongStart}%{visible} of %{total}%{strongEnd} files are displayed.', ), - { visible, total }, + { visible, total } /* eslint-disable-line @gitlab/vue-no-new-non-primitive-in-template */, ) " > diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue index 8871be1f9af..bd040cd1ba1 100644 --- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue +++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue @@ -132,10 +132,10 @@ export default { <design-note-pin v-if="canComment && currentCommentForm" - :position="{ + :position="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { left: `${currentCommentForm.xPercent}%`, top: `${currentCommentForm.yPercent}%`, - }" + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" /> </div> </template> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index bbe27c0dbd6..6c0c9c4e1d0 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -1,6 +1,7 @@ export const INLINE_DIFF_VIEW_TYPE = 'inline'; export const PARALLEL_DIFF_VIEW_TYPE = 'parallel'; export const MATCH_LINE_TYPE = 'match'; +export const EXPANDED_LINE_TYPE = 'expanded'; export const OLD_NO_NEW_LINE_TYPE = 'old-nonewline'; export const NEW_NO_NEW_LINE_TYPE = 'new-nonewline'; export const CONTEXT_LINE_TYPE = 'context'; @@ -101,6 +102,8 @@ export const CONFLICT_MARKER_THEIR = 'conflict_marker_their'; // Tracking events export const DEFER_DURATION = 750; +export const TRACKING_CAP_KEY = 'code_review_events_dispatched'; +export const TRACKING_CAP_LENGTH = 86400000; // 24 hours export const TRACKING_CLICK_DIFF_VIEW_SETTING = 'i_code_review_click_diff_view_setting'; export const TRACKING_DIFF_VIEW_INLINE = 'i_code_review_diff_view_inline'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index fb35114c0a9..d2b798245fc 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -4,6 +4,7 @@ import { DIFF_FILE_MANUAL_COLLAPSE, DIFF_FILE_AUTOMATIC_COLLAPSE, INLINE_DIFF_LINES_KEY, + EXPANDED_LINE_TYPE, } from '../constants'; import * as types from './mutation_types'; import { @@ -131,6 +132,7 @@ export default { : line.line_code || `${fileHash}_${line.old_line}_${line.new_line}`; return { ...line, + type: line.type || EXPANDED_LINE_TYPE, line_code: lineCode, discussions: line.discussions || [], hasForm: false, diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index f2028892a5f..92f3cf83740 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -15,13 +15,15 @@ import { CONFLICT_MARKER, CONFLICT_MARKER_OUR, CONFLICT_MARKER_THEIR, + EXPANDED_LINE_TYPE, } from '../constants'; import { prepareRawDiffFile } from '../utils/diff_file'; export const isAdded = (line) => ['new', 'new-nonewline'].includes(line.type); export const isRemoved = (line) => ['old', 'old-nonewline'].includes(line.type); export const isUnchanged = (line) => !line.type; -export const isMeta = (line) => ['match', 'new-nonewline', 'old-nonewline'].includes(line.type); +export const isMeta = (line) => + ['match', EXPANDED_LINE_TYPE, 'new-nonewline', 'old-nonewline'].includes(line.type); export const isConflictMarker = (line) => [CONFLICT_MARKER_OUR, CONFLICT_MARKER_THEIR].includes(line.type); export const isConflictSeperator = (line) => line.type === CONFLICT_MARKER; @@ -60,7 +62,7 @@ export const parallelizeDiffLines = (diffLines, inline) => { const line = diffLines[i]; line.chunk = chunk; - if (isMeta(line)) chunk += 1; + if (isMeta(line) && line.type !== EXPANDED_LINE_TYPE) chunk += 1; if (isRemoved(line) || isConflictOur(line) || inline) { lines.push({ @@ -205,7 +207,7 @@ export const findIndexInInlineLines = (lines, lineNumbers) => { ); }; -export const getPreviousLineIndex = (diffViewType, file, lineNumbers) => { +export const getPreviousLineIndex = (file, lineNumbers) => { return findIndexInInlineLines(file[INLINE_DIFF_LINES_KEY], lineNumbers); }; @@ -406,7 +408,7 @@ function deduplicateFilesList(files) { export function prepareDiffData({ diff, priorFiles = [], meta = false }) { const cleanedFiles = (diff.diff_files || []) - .map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles, meta })) + .map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles, meta, index })) .map(ensureBasicDiffFileLines) .map(prepareDiffFileLines) .map((file) => finalizeDiffFile(file)); diff --git a/app/assets/javascripts/diffs/utils/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js index 54dcf70c491..bcd9fa01278 100644 --- a/app/assets/javascripts/diffs/utils/diff_file.js +++ b/app/assets/javascripts/diffs/utils/diff_file.js @@ -50,7 +50,7 @@ function identifier(file) { export const isNotDiffable = (file) => file?.viewer?.name === viewerModes.not_diffable; -export function prepareRawDiffFile({ file, allFiles, meta = false }) { +export function prepareRawDiffFile({ file, allFiles, meta = false, index = -1 }) { const additionalProperties = { brokenSymlink: fileSymlinkInformation(file, allFiles), viewer: { @@ -66,6 +66,10 @@ export function prepareRawDiffFile({ file, allFiles, meta = false }) { additionalProperties.id = identifier(file); } + if (index >= 0 && Number(index) === index) { + additionalProperties.order = index; + } + return Object.assign(file, additionalProperties); } @@ -89,6 +93,27 @@ export function getShortShaFromFile(file) { return file.content_sha ? truncateSha(String(file.content_sha)) : null; } +export function match({ fileA, fileB, mode = 'universal' } = {}) { + const matching = { + universal: (a, b) => (a?.id && b?.id ? a.id === b.id : false), + /* + * MR mode can be wildly incorrect if there is ever the possibility of files from multiple MRs + * (e.g. a browser-local merge request/file cache). + * That's why the default here is "universal" mode: UUIDs can't conflict, but you can opt into + * the dangerous one. + * + * For reference: + * file_identifier_hash === sha1( `${filePath}-${Boolean(isNew)}-${Boolean(isDeleted)}-${Boolean(isRenamed)}` ) + */ + mr: (a, b) => + a?.file_identifier_hash && b?.file_identifier_hash + ? a.file_identifier_hash === b.file_identifier_hash + : false, + }; + + return (matching[mode] || (() => false))(fileA, fileB); +} + export function stats(file) { let valid = false; let classes = ''; diff --git a/app/assets/javascripts/diffs/utils/queue_events.js b/app/assets/javascripts/diffs/utils/queue_events.js index 08fcc98d45f..cc7141df622 100644 --- a/app/assets/javascripts/diffs/utils/queue_events.js +++ b/app/assets/javascripts/diffs/utils/queue_events.js @@ -1,13 +1,31 @@ import { delay } from 'lodash'; import api from '~/api'; -import { DEFER_DURATION } from '../constants'; +import { DEFER_DURATION, TRACKING_CAP_KEY, TRACKING_CAP_LENGTH } from '../constants'; -function trackRedisHllUserEvent(event, deferDuration = 0) { +function shouldDispatchEvent() { + const timestamp = parseInt(localStorage.getItem(TRACKING_CAP_KEY), 10); + + if (Number.isNaN(timestamp)) { + return true; + } + + return timestamp + TRACKING_CAP_LENGTH < Date.now(); +} + +export function dispatchRedisHllUserEvent(event, deferDuration = 0) { delay(() => api.trackRedisHllUserEvent(event), deferDuration); } -export function queueRedisHllEvents(events) { +export function queueRedisHllEvents(events, { verifyCap = false } = {}) { + if (verifyCap) { + if (!shouldDispatchEvent()) { + return; + } + + localStorage.setItem(TRACKING_CAP_KEY, Date.now()); + } + events.forEach((event, index) => { - trackRedisHllUserEvent(event, DEFER_DURATION * (index + 1)); + dispatchRedisHllUserEvent(event, DEFER_DURATION * (index + 1)); }); } diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar.vue b/app/assets/javascripts/editor/components/source_editor_toolbar.vue index 1427f2df461..2c177634bbe 100644 --- a/app/assets/javascripts/editor/components/source_editor_toolbar.vue +++ b/app/assets/javascripts/editor/components/source_editor_toolbar.vue @@ -55,8 +55,8 @@ export default { id="se-toolbar" class="gl-py-3 gl-px-5 gl-bg-white gl-border-t gl-border-b gl-display-flex gl-justify-content-space-between gl-align-items-center" > - <template v-for="group in $options.groups"> - <gl-button-group v-if="hasGroupItems(group)" :key="group"> + <div v-for="group in $options.groups" :key="group"> + <gl-button-group v-if="hasGroupItems(group)"> <template v-for="item in getGroupItems(group)"> <source-editor-toolbar-button :key="item.id" @@ -65,6 +65,6 @@ export default { /> </template> </gl-button-group> - </template> + </div> </section> </template> diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue index 2595d67af34..194b482c12e 100644 --- a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue +++ b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue @@ -1,7 +1,5 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import updateToolbarItemMutation from '~/editor/graphql/update_item.mutation.graphql'; -import getToolbarItemQuery from '~/editor/graphql/get_item.query.graphql'; export default { name: 'SourceEditorToolbarButton', @@ -20,70 +18,40 @@ export default { }, }, }, - data() { - return { - buttonItem: this.button, - }; - }, - apollo: { - buttonItem: { - query: getToolbarItemQuery, - variables() { - return { - id: this.button.id, - }; - }, - update({ item }) { - return item; - }, - skip() { - return !this.button.id; - }, - }, - }, computed: { icon() { - return this.buttonItem.selected - ? this.buttonItem.selectedIcon || this.buttonItem.icon - : this.buttonItem.icon; + return this.button.selected ? this.button.selectedIcon || this.button.icon : this.button.icon; }, label() { - return this.buttonItem.selected - ? this.buttonItem.selectedLabel || this.buttonItem.label - : this.buttonItem.label; + return this.button.selected + ? this.button.selectedLabel || this.button.label + : this.button.label; + }, + showButton() { + return Object.entries(this.button).length > 0; }, }, methods: { clickHandler() { - if (this.buttonItem.onClick) { - this.buttonItem.onClick(); + if (this.button.onClick) { + this.button.onClick(); } - this.$apollo.mutate({ - mutation: updateToolbarItemMutation, - variables: { - id: this.buttonItem.id, - propsToUpdate: { - selected: !this.buttonItem.selected, - }, - }, - }); this.$emit('click'); }, }, }; </script> <template> - <div> - <gl-button - v-gl-tooltip.hover - :category="buttonItem.category" - :variant="buttonItem.variant" - type="button" - :selected="buttonItem.selected" - :icon="icon" - :title="label" - :aria-label="label" - @click="clickHandler" - /> - </div> + <gl-button + v-if="showButton" + v-gl-tooltip.hover + :category="button.category" + :variant="button.variant" + type="button" + :selected="button.selected" + :icon="icon" + :title="label" + :aria-label="label" + @click="clickHandler" + /> </template> diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar_graphql.js b/app/assets/javascripts/editor/components/source_editor_toolbar_graphql.js new file mode 100644 index 00000000000..603ba26f22e --- /dev/null +++ b/app/assets/javascripts/editor/components/source_editor_toolbar_graphql.js @@ -0,0 +1,53 @@ +import produce from 'immer'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import typeDefs from '~/editor/graphql/typedefs.graphql'; +import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +const resolvers = { + Mutation: { + addToolbarItems: (_, { items = [] }, { cache }) => { + const itemsSourceData = cache.readQuery({ query: getToolbarItemsQuery }); + const data = produce(itemsSourceData, (draftData) => { + const existingNodes = draftData?.items?.nodes || []; + draftData.items = { + nodes: Array.isArray(items) ? [...existingNodes, ...items] : [...existingNodes, items], + }; + }); + cache.writeQuery({ query: getToolbarItemsQuery, data }); + }, + + removeToolbarItems: (_, { ids }, { cache }) => { + const sourceData = cache.readQuery({ query: getToolbarItemsQuery }); + const { + items: { nodes }, + } = sourceData; + const data = produce(sourceData, (draftData) => { + draftData.items.nodes = nodes.filter((item) => !ids.includes(item.id)); + }); + cache.writeQuery({ query: getToolbarItemsQuery, data }); + }, + + updateToolbarItem: (_, { id, propsToUpdate }, { cache }) => { + const itemSourceData = cache.readQuery({ query: getToolbarItemsQuery }); + const data = produce(itemSourceData, (draftData) => { + const existingNodes = draftData?.items?.nodes || []; + draftData.items = { + nodes: existingNodes.map((item) => { + return item.id === id ? { ...item, ...propsToUpdate } : item; + }), + }; + }); + cache.writeQuery({ query: getToolbarItemsQuery, data }); + }, + }, +}; + +const defaultClient = createDefaultClient(resolvers, { typeDefs }); + +export const apolloProvider = new VueApollo({ + defaultClient, +}); diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index 361122d8890..83cfdd25757 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -1,5 +1,5 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; export const URI_PREFIX = 'gitlab'; export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; @@ -57,5 +57,8 @@ export const EXTENSION_CI_SCHEMA_FILE_NAME_MATCH = '.gitlab-ci.yml'; export const EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS = 'md'; export const EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS = 'source-editor-preview'; export const EXTENSION_MARKDOWN_PREVIEW_ACTION_ID = 'markdown-preview'; +export const EXTENSION_MARKDOWN_PREVIEW_HIDE_ACTION_ID = 'markdown-preview-hide'; export const EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH = 0.5; // 50% of the width export const EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY = 250; // ms +export const EXTENSION_MARKDOWN_PREVIEW_LABEL = __('Preview Markdown'); +export const EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL = __('Hide Live Preview'); diff --git a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js index 3aa19df964c..0590bb7455a 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js +++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js @@ -5,7 +5,7 @@ import { EXTENSION_BASE_LINE_NUMBERS_CLASS, } from '../constants'; -const hashRegexp = new RegExp('#?L', 'g'); +const hashRegexp = /#?L/g; const createAnchor = (href) => { const fragment = new DocumentFragment(); @@ -64,7 +64,7 @@ export class SourceEditorExtension { const [start, end] = bounds && Array.isArray(bounds) ? bounds - : window.location.hash?.replace(hashRegexp, '').split('-'); + : window.location.hash.replace(hashRegexp, '').split('-'); let startLine = start ? parseInt(start, 10) : null; let endLine = end ? parseInt(end, 10) : startLine; if (endLine < startLine) { diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js index 9d53268c340..11cc85c659d 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js @@ -3,14 +3,17 @@ import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants'; import createFlash from '~/flash'; import { sanitize } from '~/lib/dompurify'; import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; import syntaxHighlight from '~/syntax_highlight'; import { EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + EXTENSION_MARKDOWN_PREVIEW_HIDE_ACTION_ID, EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, + EXTENSION_MARKDOWN_PREVIEW_LABEL, + EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL, + EDITOR_TOOLBAR_RIGHT_GROUP, } from '../constants'; const fetchPreview = (text, previewMarkdownPath) => { @@ -41,31 +44,58 @@ export class EditorMarkdownPreviewExtension { onSetup(instance, setupOptions) { this.preview = { el: undefined, - action: undefined, + actions: { + preview: undefined, + hide: undefined, + }, shown: false, modelChangeListener: undefined, path: setupOptions.previewMarkdownPath, + actionShowPreviewCondition: instance.createContextKey('toggleLivePreview', true), }; + this.toolbarButtons = []; + this.setupPreviewAction(instance); + if (instance.toolbar) { + this.setupToolbar(instance); + } + } - instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => { - if (newLanguage === 'markdown' && oldLanguage !== newLanguage) { - instance.setupPreviewAction(); - } else { - instance.cleanup(); - } - }); + onBeforeUnuse(instance) { + this.cleanup(instance); + const ids = this.toolbarButtons.map((item) => item.id); + if (instance.toolbar) { + instance.toolbar.removeItems(ids); + } + } - instance.onDidChangeModel(() => { - const model = instance.getModel(); - if (model) { - const { language } = model.getLanguageIdentifier(); - instance.cleanup(); - if (language === 'markdown') { - instance.setupPreviewAction(); - } - } - }); + cleanup(instance) { + if (this.preview.modelChangeListener) { + this.preview.modelChangeListener.dispose(); + } + this.preview.actions.preview.dispose(); + this.preview.actions.hide.dispose(); + if (this.preview.shown) { + this.togglePreviewPanel(instance); + this.togglePreviewLayout(instance); + } + this.preview.shown = false; + } + + setupToolbar(instance) { + this.toolbarButtons = [ + { + id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + label: EXTENSION_MARKDOWN_PREVIEW_LABEL, + icon: 'live-preview', + selected: false, + group: EDITOR_TOOLBAR_RIGHT_GROUP, + category: 'primary', + selectedLabel: EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL, + onClick: () => instance.togglePreview(), + }, + ]; + instance.toolbar.addItems(this.toolbarButtons); } togglePreviewLayout(instance) { @@ -103,22 +133,33 @@ export class EditorMarkdownPreviewExtension { setupPreviewAction(instance) { if (instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return; - - this.preview.action = instance.addAction({ - id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, - label: __('Preview Markdown'), + const actionBasis = { keybindings: [ // eslint-disable-next-line no-bitwise,no-undef monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P), ], contextMenuGroupId: 'navigation', contextMenuOrder: 1.5, - // Method that will be executed when the action is triggered. // @param ed The editor instance is passed in as a convenience run(inst) { inst.togglePreview(); }, + }; + + this.preview.actions.preview = instance.addAction({ + ...actionBasis, + id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + label: EXTENSION_MARKDOWN_PREVIEW_LABEL, + + precondition: 'toggleLivePreview', + }); + this.preview.actions.hide = instance.addAction({ + ...actionBasis, + id: EXTENSION_MARKDOWN_PREVIEW_HIDE_ACTION_ID, + label: EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL, + + precondition: '!toggleLivePreview', }); } @@ -126,18 +167,6 @@ export class EditorMarkdownPreviewExtension { return { markdownPreview: this.preview, - cleanup: (instance) => { - if (this.preview.modelChangeListener) { - this.preview.modelChangeListener.dispose(); - } - this.preview.action.dispose(); - if (this.preview.shown) { - this.togglePreviewPanel(instance); - this.togglePreviewLayout(instance); - } - this.preview.shown = false; - }, - fetchPreview: (instance) => this.fetchPreview(instance), setupPreviewAction: (instance) => this.setupPreviewAction(instance), @@ -149,6 +178,8 @@ export class EditorMarkdownPreviewExtension { this.togglePreviewLayout(instance); this.togglePreviewPanel(instance); + this.preview.actionShowPreviewCondition.set(!this.preview.actionShowPreviewCondition.get()); + if (!this.preview?.shown) { this.preview.modelChangeListener = instance.onDidChangeModelContent( debounce( @@ -161,6 +192,11 @@ export class EditorMarkdownPreviewExtension { } this.preview.shown = !this.preview?.shown; + if (instance.toolbar) { + instance.toolbar.updateItem(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, { + selected: this.preview.shown, + }); + } }, }; } diff --git a/app/assets/javascripts/editor/extensions/source_editor_toolbar_ext.js b/app/assets/javascripts/editor/extensions/source_editor_toolbar_ext.js new file mode 100644 index 00000000000..9655c8ae76a --- /dev/null +++ b/app/assets/javascripts/editor/extensions/source_editor_toolbar_ext.js @@ -0,0 +1,98 @@ +import Vue from 'vue'; +import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql'; +import removeToolbarItemsMutation from '~/editor/graphql/remove_items.mutation.graphql'; +import updateToolbarItemMutation from '~/editor/graphql/update_item.mutation.graphql'; +import addToolbarItemsMutation from '~/editor/graphql/add_items.mutation.graphql'; +import SourceEditorToolbar from '~/editor/components/source_editor_toolbar.vue'; +import { apolloProvider } from '~/editor/components/source_editor_toolbar_graphql'; + +const client = apolloProvider.defaultClient; + +export class ToolbarExtension { + /** + * A required getter returning the extension's name + * We have to provide it for every extension instead of relying on the built-in + * `name` prop because the prop does not survive the webpack's minification + * and the name mangling. + * @returns {string} + */ + static get extensionName() { + return 'ToolbarExtension'; + } + /** + * THE LIFE-CYCLE CALLBACKS + */ + + /** + * Is called before the extension gets used by an instance, + * Use `onSetup` to setup Monaco directly: + * actions, keystrokes, update options, etc. + * Is called only once before the extension gets registered + * + * @param { Object } [instance] The Source Editor instance + * @param { Object } [setupOptions] The setupOptions object + */ + // eslint-disable-next-line class-methods-use-this + onSetup(instance, setupOptions) { + const el = setupOptions?.el || document.getElementById('editor-toolbar'); + ToolbarExtension.setupVue(el); + } + + static setupVue(el) { + client.cache.writeQuery({ query: getToolbarItemsQuery, data: { items: { nodes: [] } } }); + const ToolbarComponent = Vue.extend(SourceEditorToolbar); + + const toolbar = new ToolbarComponent({ + el, + apolloProvider, + }); + toolbar.$mount(); + } + + /** + * The public API of the extension: these are the methods that will be exposed + * to the end user + * @returns {Object} + */ + // eslint-disable-next-line class-methods-use-this + provides() { + return { + toolbar: { + getItem: (id) => { + const items = client.readQuery({ query: getToolbarItemsQuery })?.items?.nodes || []; + return items.find((item) => item.id === id); + }, + getAllItems: () => { + return client.readQuery({ query: getToolbarItemsQuery })?.items?.nodes || []; + }, + addItems: (items = []) => { + return client.mutate({ + mutation: addToolbarItemsMutation, + variables: { + items, + }, + }); + }, + removeItems: (ids = []) => { + client.mutate({ + mutation: removeToolbarItemsMutation, + variables: { + ids, + }, + }); + }, + updateItem: (id = '', propsToUpdate = {}) => { + if (id) { + client.mutate({ + mutation: updateToolbarItemMutation, + variables: { + id, + propsToUpdate, + }, + }); + } + }, + }, + }; + } +} diff --git a/app/assets/javascripts/editor/graphql/add_items.mutation.graphql b/app/assets/javascripts/editor/graphql/add_items.mutation.graphql new file mode 100644 index 00000000000..13afcc04a48 --- /dev/null +++ b/app/assets/javascripts/editor/graphql/add_items.mutation.graphql @@ -0,0 +1,3 @@ +mutation addItems($items: [Item]) { + addToolbarItems(items: $items) @client +} diff --git a/app/assets/javascripts/editor/graphql/get_item.query.graphql b/app/assets/javascripts/editor/graphql/get_item.query.graphql deleted file mode 100644 index 7c8bc09f7b0..00000000000 --- a/app/assets/javascripts/editor/graphql/get_item.query.graphql +++ /dev/null @@ -1,9 +0,0 @@ -query ToolbarItem($id: String!) { - item(id: $id) @client { - id - label - icon - selected - group - } -} diff --git a/app/assets/javascripts/editor/graphql/remove_items.mutation.graphql b/app/assets/javascripts/editor/graphql/remove_items.mutation.graphql new file mode 100644 index 00000000000..627f105b0ec --- /dev/null +++ b/app/assets/javascripts/editor/graphql/remove_items.mutation.graphql @@ -0,0 +1,3 @@ +mutation removeToolbarItems($ids: [ID!]) { + removeToolbarItems(ids: $ids) @client +} diff --git a/app/assets/javascripts/editor/graphql/typedefs.graphql b/app/assets/javascripts/editor/graphql/typedefs.graphql new file mode 100644 index 00000000000..2433ebf6c66 --- /dev/null +++ b/app/assets/javascripts/editor/graphql/typedefs.graphql @@ -0,0 +1,23 @@ +type Item { + id: ID! + label: String! + icon: String + selected: Boolean + group: Int! + category: String + selectedLabel: String +} + +type Items { + nodes: [Item]! +} + +extend type Query { + items: Items +} + +extend type Mutation { + updateToolbarItem(id: ID!, propsToUpdate: Item!): LocalErrors + removeToolbarItems(ids: [ID!]): LocalErrors + addToolbarItems(items: [Item]): LocalErrors +} diff --git a/app/assets/javascripts/editor/graphql/update_item.mutation.graphql b/app/assets/javascripts/editor/graphql/update_item.mutation.graphql index f8424c65181..05c18988c87 100644 --- a/app/assets/javascripts/editor/graphql/update_item.mutation.graphql +++ b/app/assets/javascripts/editor/graphql/update_item.mutation.graphql @@ -1,3 +1,3 @@ -mutation updateItem($id: String!, $propsToUpdate: Item!) { +mutation updateItem($id: ID!, $propsToUpdate: Item!) { updateToolbarItem(id: $id, propsToUpdate: $propsToUpdate) @client } diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index fe3229ac91b..1352211b927 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -170,23 +170,6 @@ } ] }, - "cobertura": { - "description": "Path for file(s) that should be parsed as Cobertura XML coverage report", - "oneOf": [ - { - "type": "string", - "description": "Path to a single XML file" - }, - { - "type": "array", - "description": "A list of paths to XML files that will automatically be merged into one report", - "items": { - "type": "string" - }, - "minItems": 1 - } - ] - }, "coverage_report": { "type": "object", "description": "Used to collect coverage reports from the job.", @@ -1093,8 +1076,8 @@ "description": "The name of a job to execute when the environment is about to be stopped." }, "action": { - "enum": ["start", "prepare", "stop"], - "description": "Specifies what this job will do. 'start' (default) indicates the job will start the deployment. 'prepare' indicates this will not affect the deployment. 'stop' indicates this will stop the deployment.", + "enum": ["start", "prepare", "stop", "verify", "access"], + "description": "Specifies what this job will do. 'start' (default) indicates the job will start the deployment. 'prepare'/'verify'/'access' indicates this will not affect the deployment. 'stop' indicates this will stop the deployment.", "default": "start" }, "auto_stop_in": { diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue index 4d43ee156fb..6343fe8702a 100644 --- a/app/assets/javascripts/environments/components/enable_review_app_modal.vue +++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue @@ -45,6 +45,8 @@ export default { copyToClipboardText: s__('EnableReviewApp|Copy snippet text'), title: s__('ReviewApp|Enable Review App'), }, + visualReviewsDocs: helpPagePath('ci/review_apps/index.md', { anchor: 'visual-reviews' }), + connectClusterDocs: helpPagePath('user/clusters/agent/index'), data() { const modalInfoCopyId = uniqueId('enable-review-app-copy-string-'); @@ -64,9 +66,6 @@ export default { except: - ${this.defaultBranchName}`; }, - visualReviewsDocs() { - return helpPagePath('ci/review_apps/index.md', { anchor: 'visual-reviews' }); - }, }, }; </script> @@ -88,11 +87,7 @@ export default { <strong>{{ content }}</strong> </template> <template #link="{ content }"> - <gl-link - href="https://docs.gitlab.com/ee/user/project/clusters/add_remove_clusters.html" - target="_blank" - >{{ content }}</gl-link - > + <gl-link :href="$options.connectClusterDocs" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </p> @@ -134,7 +129,7 @@ export default { <strong>{{ content }}</strong> </template> <template #link="{ content }"> - <gl-link :href="visualReviewsDocs" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.visualReviewsDocs" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/environments/components/environment_folder.vue b/app/assets/javascripts/environments/components/environment_folder.vue index d5c6d26cfd0..788c3ba6fed 100644 --- a/app/assets/javascripts/environments/components/environment_folder.vue +++ b/app/assets/javascripts/environments/components/environment_folder.vue @@ -34,9 +34,6 @@ export default { variables() { return { environment: this.nestedEnvironment.latest, scope: this.scope }; }, - pollInterval() { - return this.interval; - }, }, interval: { query: pollIntervalQuery, @@ -73,6 +70,11 @@ export default { methods: { toggleCollapse() { this.visible = !this.visible; + if (this.visible) { + this.$apollo.queries.folder.startPolling(this.interval); + } else { + this.$apollo.queries.folder.stopPolling(); + } }, isFirstEnvironment(index) { return index === 0; diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 7b8b756487b..7fcd6e5fff8 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -146,7 +146,7 @@ export default { {{ tableData.autoStop.title }} </div> </div> - <template v-for="(model, i) in sortedEnvironments" :model="model"> + <template v-for="(model, i) in sortedEnvironments"> <environment-item :key="`environment-item-${i}`" :model="model" diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index 3c608ad0ba9..adb14ce3d6f 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -54,7 +54,9 @@ export default { :key="`${tab.name}-${i}`" :active="tab.isActive" :title-item-class="tab.isActive ? 'gl-outline-none' : ''" - :title-link-attributes="{ 'data-testid': `environments-tab-${tab.scope}` }" + :title-link-attributes="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + 'data-testid': `environments-tab-${tab.scope}`, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @click="onChangeTab(tab.scope)" > <template #title> diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 3d540d46b3c..86102fd54b1 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -254,7 +254,7 @@ export default { data-testid="integrated-disabled-alert" @dismiss="isAlertDismissed = true" > - <gl-sprintf :message="this.$options.i18n.integratedErrorTrackingDisabledText"> + <gl-sprintf :message="$options.i18n.integratedErrorTrackingDisabledText"> <template #epicLink="{ content }"> <gl-link :href="$options.epicLink" target="_blank">{{ content }}</gl-link> </template> diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql index f70e09d76f7..dd21b0f9c92 100644 --- a/app/assets/javascripts/error_tracking/queries/details.query.graphql +++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql @@ -1,4 +1,4 @@ -query errorDetails($fullPath: ID!, $errorId: ID!) { +query errorDetails($fullPath: ID!, $errorId: GitlabErrorTrackingDetailedErrorID!) { project(fullPath: $fullPath) { id sentryErrors { diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue index e850d954e0a..70fb1fa9cd7 100644 --- a/app/assets/javascripts/error_tracking_settings/components/app.vue +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -152,7 +152,7 @@ export default { <template> <div> <gl-alert v-if="showIntegratedTrackingDisabledAlert" variant="danger" @dismiss="dismissAlert"> - <gl-sprintf :message="this.$options.i18n.integratedErrorTrackingDisabledText"> + <gl-sprintf :message="$options.i18n.integratedErrorTrackingDisabledText"> <template #epicLink="{ content }"> <gl-link :href="$options.epicLink" target="_blank">{{ content }}</gl-link> </template> diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index 09cef74477c..b57db73a86e 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -1,18 +1,15 @@ import { sortMilestonesByDueDate } from '~/milestones/utils'; import { mergeUrlParams } from '../lib/utils/url_utility'; -import DropdownAjaxFilter from './dropdown_ajax_filter'; import DropdownEmoji from './dropdown_emoji'; import DropdownHint from './dropdown_hint'; import DropdownNonUser from './dropdown_non_user'; import DropdownOperator from './dropdown_operator'; import DropdownUser from './dropdown_user'; import DropdownUtils from './dropdown_utils'; -import NullDropdown from './null_dropdown'; export default class AvailableDropdownMappings { constructor({ container, - runnerTagsEndpoint, labelsEndpoint, milestonesEndpoint, releasesEndpoint, @@ -22,7 +19,6 @@ export default class AvailableDropdownMappings { includeDescendantGroups, }) { this.container = container; - this.runnerTagsEndpoint = runnerTagsEndpoint; this.labelsEndpoint = labelsEndpoint; this.milestonesEndpoint = milestonesEndpoint; this.releasesEndpoint = releasesEndpoint; @@ -135,25 +131,6 @@ export default class AvailableDropdownMappings { gl: DropdownNonUser, element: this.container.querySelector('#js-dropdown-confidential'), }, - status: { - reference: null, - gl: NullDropdown, - element: this.container.querySelector('#js-dropdown-admin-runner-status'), - }, - type: { - reference: null, - gl: NullDropdown, - element: this.container.querySelector('#js-dropdown-admin-runner-type'), - }, - tag: { - reference: null, - gl: DropdownAjaxFilter, - extraArguments: { - endpoint: this.getRunnerTagsEndpoint(), - symbol: '~', - }, - element: this.container.querySelector('#js-dropdown-runner-tag'), - }, 'target-branch': { reference: null, gl: DropdownNonUser, @@ -202,10 +179,6 @@ export default class AvailableDropdownMappings { return endpoint; } - getRunnerTagsEndpoint() { - return `${this.runnerTagsEndpoint}.json`; - } - getMergeRequestTargetBranchesEndpoint() { const endpoint = `${ gon.relative_url_root || '' diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index f8b5910de9e..e07dccd11e8 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -15,6 +15,4 @@ export const MAX_HISTORY_SIZE = 5; export const FILTERED_SEARCH = { MERGE_REQUESTS: 'merge_requests', ISSUES: 'issues', - ADMIN_RUNNERS: 'admin/runners', - GROUP_RUNNERS_ANCHOR: 'runners-settings', }; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index e467e97dda9..7471d3204d6 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -9,7 +9,6 @@ import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; export default class FilteredSearchDropdownManager { constructor({ - runnerTagsEndpoint = '', labelsEndpoint = '', milestonesEndpoint = '', iterationsEndpoint = '', @@ -26,7 +25,6 @@ export default class FilteredSearchDropdownManager { const removeTrailingSlash = (url) => url.replace(/\/$/, ''); this.container = FilteredSearchContainer.container; - this.runnerTagsEndpoint = removeTrailingSlash(runnerTagsEndpoint); this.labelsEndpoint = removeTrailingSlash(labelsEndpoint); this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint); this.iterationsEndpoint = removeTrailingSlash(iterationsEndpoint); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 5ba69f052c9..07f2c75f00a 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -114,7 +114,6 @@ export default class FilteredSearchManager { this.tokenizer = FilteredSearchTokenizer; const { - runnerTagsEndpoint = '', labelsEndpoint = '', milestonesEndpoint = '', releasesEndpoint = '', @@ -124,7 +123,6 @@ export default class FilteredSearchManager { } = this.filteredSearchInput.dataset; this.dropdownManager = new FilteredSearchDropdownManager({ - runnerTagsEndpoint, labelsEndpoint, milestonesEndpoint, releasesEndpoint, @@ -583,7 +581,7 @@ export default class FilteredSearchManager { * Eg. not[foo]=%bar * key = foo; value = %bar */ - const notKeyValueRegex = new RegExp(/not\[(\w+)\]\[?\]?=(.*)/); + const notKeyValueRegex = /not\[(\w+)\]\[?\]?=(.*)/; return params.map((query) => { // Check if there are matches for `not` operator diff --git a/app/assets/javascripts/filtered_search/group_runners_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/group_runners_filtered_search_token_keys.js deleted file mode 100644 index ceeb71c4eec..00000000000 --- a/app/assets/javascripts/filtered_search/group_runners_filtered_search_token_keys.js +++ /dev/null @@ -1,27 +0,0 @@ -import { __ } from '~/locale'; -import FilteredSearchTokenKeys from './filtered_search_token_keys'; - -const tokenKeys = [ - { - formattedKey: __('Status'), - key: 'status', - type: 'string', - param: 'status', - symbol: '', - icon: 'messages', - tag: 'status', - }, - { - formattedKey: __('Type'), - key: 'type', - type: 'string', - param: 'type', - symbol: '', - icon: 'cube', - tag: 'type', - }, -]; - -const GroupRunnersFilteredSearchTokenKeys = new FilteredSearchTokenKeys(tokenKeys); - -export default GroupRunnersFilteredSearchTokenKeys; diff --git a/app/assets/javascripts/filtered_search/null_dropdown.js b/app/assets/javascripts/filtered_search/null_dropdown.js deleted file mode 100644 index 4cfce2a5beb..00000000000 --- a/app/assets/javascripts/filtered_search/null_dropdown.js +++ /dev/null @@ -1,9 +0,0 @@ -import FilteredSearchDropdown from './filtered_search_dropdown'; - -export default class NullDropdown extends FilteredSearchDropdown { - renderContent(forceShowList = false) { - this.droplab.changeHookList(this.hookId, this.dropdown, [], this.config); - - super.renderContent(forceShowList); - } -} diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 24ec16bf20e..5a47e76d597 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -86,48 +86,43 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => { /** * Render an alert at the top of the page, or, optionally an - * arbitrary existing container. - * - * This alert is always dismissible. - * - * Usage: - * - * 1. Render a new alert + * arbitrary existing container. This alert is always dismissible. * + * @example + * // Render a new alert * import { createAlert, VARIANT_WARNING } from '~/flash'; * * createAlert({ message: 'My error message' }); * createAlert({ message: 'My warning message', variant: VARIANT_WARNING }); * - * 2. Dismiss this alert programmatically - * + * @example + * // Dismiss this alert programmatically * const alert = createAlert({ message: 'Message' }); * * // ... * * alert.dismiss(); * - * 3. Respond to the alert being dismissed + * @example + * // Respond to the alert being dismissed + * createAlert({ message: 'Message', onDismiss: () => {} }); * - * createAlert({ message: 'Message', onDismiss: () => { ... }}); - * - * @param {Object} options Options to control the flash message - * @param {String} options.message Alert message text - * @param {String?} options.variant Which GlAlert variant to use, should be VARIANT_SUCCESS, VARIANT_WARNING, VARIANT_DANGER, VARIANT_INFO or VARIANT_TIP. Defaults to VARIANT_DANGER. - * @param {Object?} options.parent Reference to parent element under which alert needs to appear. Defaults to `document`. - * @param {Function?} options.onDismiss Handler to call when this alert is dismissed. - * @param {Object?} options.containerSelector Selector for the container of the alert - * @param {Object?} options.primaryButton Object describing primary button of alert - * @param {String?} link Href of primary button - * @param {String?} text Text of primary button - * @param {Function?} clickHandler Handler to call when primary button is clicked on. The click event is sent as an argument. - * @param {Object?} options.secondaryButton Object describing secondary button of alert - * @param {String?} link Href of secondary button - * @param {String?} text Text of secondary button - * @param {Function?} clickHandler Handler to call when secondary button is clicked on. The click event is sent as an argument. - * @param {Boolean?} options.captureError Whether to send error to Sentry - * @param {Object} options.error Error to be captured in Sentry - * @returns + * @param {object} options - Options to control the flash message + * @param {string} options.message - Alert message text + * @param {VARIANT_SUCCESS|VARIANT_WARNING|VARIANT_DANGER|VARIANT_INFO|VARIANT_TIP} [options.variant] - Which GlAlert variant to use; it defaults to VARIANT_DANGER. + * @param {object} [options.parent] - Reference to parent element under which alert needs to appear. Defaults to `document`. + * @param {Function} [options.onDismiss] - Handler to call when this alert is dismissed. + * @param {string} [options.containerSelector] - Selector for the container of the alert + * @param {object} [options.primaryButton] - Object describing primary button of alert + * @param {string} [options.primaryButton.link] - Href of primary button + * @param {string} [options.primaryButton.text] - Text of primary button + * @param {Function} [options.primaryButton.clickHandler] - Handler to call when primary button is clicked on. The click event is sent as an argument. + * @param {object} [options.secondaryButton] - Object describing secondary button of alert + * @param {string} [options.secondaryButton.link] - Href of secondary button + * @param {string} [options.secondaryButton.text] - Text of secondary button + * @param {Function} [options.secondaryButton.clickHandler] - Handler to call when secondary button is clicked on. The click event is sent as an argument. + * @param {boolean} [options.captureError] - Whether to send error to Sentry + * @param {object} [options.error] - Error to be captured in Sentry */ const createAlert = function createAlert({ message, @@ -207,22 +202,25 @@ const createAlert = function createAlert({ }); }; -/* - * Flash banner supports different types of Flash configurations - * along with ability to provide actionConfig which can be used to show - * additional action or link on banner next to message +/** + * @deprecated use `createAlert` instead + * + * Flash banner supports different types of Flash configurations + * along with ability to provide actionConfig which can be used to show + * additional action or link on banner next to message * - * @param {Object} options Options to control the flash message - * @param {String} options.message Flash message text - * @param {String} options.type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default) - * @param {Object} options.parent Reference to parent element under which Flash needs to appear - * @param {Object} options.actionConfig Map of config to show action on banner - * @param {String} href URL to which action config should point to (default: '#') - * @param {String} title Title of action - * @param {Function} clickHandler Method to call when action is clicked on - * @param {Boolean} options.fadeTransition Boolean to determine whether to fade the alert out - * @param {Boolean} options.captureError Boolean to determine whether to send error to Sentry - * @param {Object} options.error Error to be captured in Sentry + * @param {object} options - Options to control the flash message + * @param {string} options.message - Flash message text + * @param {'alert'|'notice'|'success'|'warning'} [options.type] - Type of Flash; it defaults to 'alert' + * @param {Element|Document} [options.parent] - Reference to parent element under which Flash needs to appear + * @param {object} [options.actionConfig] - Map of config to show action on banner + * @param {string} [options.actionConfig.href] - URL to which action config should point to (default: '#') + * @param {string} [options.actionConfig.title] - Title of action + * @param {Function} [options.actionConfig.clickHandler] - Method to call when action is clicked on + * @param {boolean} [options.fadeTransition] - Boolean to determine whether to fade the alert out + * @param {boolean} [options.addBodyClass] - Adds `flash-shown` class to the `body` element + * @param {boolean} [options.captureError] - Boolean to determine whether to send error to Sentry + * @param {object} [options.error] - Error to be captured in Sentry */ const createFlash = function createFlash({ message, diff --git a/app/assets/javascripts/frequent_items/constants.js b/app/assets/javascripts/frequent_items/constants.js index 9e1dcf70aa5..cb5d21161a9 100644 --- a/app/assets/javascripts/frequent_items/constants.js +++ b/app/assets/javascripts/frequent_items/constants.js @@ -7,7 +7,7 @@ export const FREQUENT_ITEMS = { ELIGIBLE_FREQUENCY: 3, }; -export const HOUR_IN_MS = 3600000; +export const FIFTEEN_MINUTES_IN_MS = 900000; export const STORAGE_KEY = { projects: 'frequent-projects', diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js index 27ef47df8c8..1c33c8b1084 100644 --- a/app/assets/javascripts/frequent_items/utils.js +++ b/app/assets/javascripts/frequent_items/utils.js @@ -1,7 +1,7 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { take } from 'lodash'; import { sanitize } from '~/lib/dompurify'; -import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants'; +import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from './constants'; export const isMobile = () => ['md', 'sm', 'xs'].includes(bp.getBreakpointSize()); @@ -38,7 +38,8 @@ export const updateExistingFrequentItem = (frequentItem, item) => { // `frequentItem` comes from localStorage and it's possible it doesn't have a `lastAccessedOn` const neverAccessed = !frequentItem.lastAccessedOn; const shouldUpdate = - neverAccessed || Math.abs(item.lastAccessedOn - frequentItem.lastAccessedOn) / HOUR_IN_MS > 1; + neverAccessed || + Math.abs(item.lastAccessedOn - frequentItem.lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1; return { ...item, diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 8cb2e9e249b..b1af1ad797b 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -844,7 +844,7 @@ class GfmAutoComplete { } } -GfmAutoComplete.regexSubtext = new RegExp(/\s+/g); +GfmAutoComplete.regexSubtext = /\s+/g; GfmAutoComplete.defaultLoadingData = ['loading']; diff --git a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql index 78b2cd34a5c..824997f8e33 100644 --- a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql @@ -1,4 +1,5 @@ fragment TimelogFragment on Timelog { + id timeSpent user { id diff --git a/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql index 429993b37bf..0b451262b5a 100644 --- a/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql @@ -1,4 +1,3 @@ -# eslint-disable-next-line @graphql-eslint/require-id-when-available fragment UserAvailability on User { status { availability diff --git a/app/assets/javascripts/graphql_shared/mutations/todo_mark_done.mutation.graphql b/app/assets/javascripts/graphql_shared/mutations/todo_mark_done.mutation.graphql index 4d59b4d94cd..e8ad919f4a8 100644 --- a/app/assets/javascripts/graphql_shared/mutations/todo_mark_done.mutation.graphql +++ b/app/assets/javascripts/graphql_shared/mutations/todo_mark_done.mutation.graphql @@ -1,4 +1,4 @@ -mutation todoMarkDone($id: ID!) { +mutation todoMarkDone($id: TodoID!) { todoMarkDone(input: { id: $id }) { errors todo { diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 3d6360fc4f8..7ca3f20ec1c 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -76,6 +76,10 @@ "Discussion", "Note" ], + "SecurityPolicySource": [ + "GroupSecurityPolicySource", + "ProjectSecurityPolicySource" + ], "Service": [ "BaseService", "JiraService" diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index b6a6720e7a1..49e7dd28ff6 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -1,8 +1,13 @@ +import { debounce } from 'lodash'; + import createFlash from '~/flash'; import { __ } from '~/locale'; import { getGroupPathAvailability } from '~/rest_api'; +import axios from '~/lib/utils/axios_utils'; import { slugify } from './lib/utils/text_utility'; +const DEBOUNCE_TIMEOUT_DURATION = 1000; + export default class Group { constructor() { this.groupPaths = Array.from(document.querySelectorAll('.js-autofill-group-path')); @@ -10,7 +15,11 @@ export default class Group { this.parentId = document.getElementById('group_parent_id'); this.updateHandler = this.update.bind(this); this.resetHandler = this.reset.bind(this); - this.updateGroupPathSlugHandler = this.updateGroupPathSlug.bind(this); + this.updateGroupPathSlugHandler = debounce( + this.updateGroupPathSlug.bind(this), + DEBOUNCE_TIMEOUT_DURATION, + ); + this.currentApiRequestController = null; this.groupNames.forEach((groupName) => { groupName.addEventListener('keyup', this.updateHandler); @@ -44,13 +53,23 @@ export default class Group { }); } - updateGroupPathSlug({ currentTarget: { value } = '' } = {}) { - const slug = this.groupPaths[0]?.value || slugify(value); + updateGroupPathSlug({ target: { value } = '' } = {}) { + if (this.currentApiRequestController !== null) { + this.currentApiRequestController.abort(); + } + + this.currentApiRequestController = new AbortController(); + + const slug = slugify(value); if (!slug) return; - getGroupPathAvailability(slug, this.parentId?.value) + getGroupPathAvailability(slug, this.parentId?.value, { + signal: this.currentApiRequestController.signal, + }) .then(({ data }) => data) .then(({ exists, suggests }) => { + this.currentApiRequestController = null; + if (exists && suggests.length) { const [suggestedSlug] = suggests; @@ -63,10 +82,14 @@ export default class Group { }); } }) - .catch(() => + .catch((error) => { + if (axios.isCancel(error)) { + return; + } + createFlash({ message: __('An error occurred while checking group path. Please refresh and try again.'), - }), - ); + }); + }); } } diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue index dcac337c6ef..3365f4aa76c 100644 --- a/app/assets/javascripts/group_settings/components/shared_runners_form.vue +++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue @@ -1,73 +1,64 @@ <script> -import { GlToggle, GlLoadingIcon, GlTooltip, GlAlert } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { GlToggle, GlAlert } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import { DEBOUNCE_TOGGLE_DELAY, ERROR_MESSAGE } from '../constants'; +import { ERROR_MESSAGE } from '../constants'; export default { components: { GlToggle, - GlLoadingIcon, - GlTooltip, GlAlert, }, inject: [ 'updatePath', - 'sharedRunnersAvailability', - 'parentSharedRunnersAvailability', - 'runnerEnabled', - 'runnerDisabled', - 'runnerAllowOverride', + 'sharedRunnersSetting', + 'parentSharedRunnersSetting', + 'runnerEnabledValue', + 'runnerDisabledValue', + 'runnerAllowOverrideValue', ], data() { return { isLoading: false, - enabled: true, - allowOverride: false, + value: this.sharedRunnersSetting, error: null, }; }, computed: { - toggleDisabled() { - return this.parentSharedRunnersAvailability === this.runnerDisabled || this.isLoading; + isSharedRunnersToggleDisabled() { + return this.parentSharedRunnersSetting === this.runnerDisabledValue; }, - enabledOrDisabledSetting() { - return this.enabled ? this.runnerEnabled : this.runnerDisabled; + sharedRunnersToggleValue() { + return this.value === this.runnerEnabledValue; }, - disabledWithOverrideSetting() { - return this.allowOverride ? this.runnerAllowOverride : this.runnerDisabled; + isOverrideToggleDisabled() { + // cannot override when sharing is enabled + return this.isSharedRunnersToggleDisabled || this.value === this.runnerEnabledValue; + }, + overrideToggleValue() { + return this.value === this.runnerAllowOverrideValue; }, - }, - created() { - if (this.sharedRunnersAvailability !== this.runnerEnabled) { - this.enabled = false; - } - - if (this.sharedRunnersAvailability === this.runnerAllowOverride) { - this.allowOverride = true; - } }, methods: { - generatePayload(data) { - return { shared_runners_setting: data }; - }, - enableOrDisable() { - this.updateRunnerSettings(this.generatePayload(this.enabledOrDisabledSetting)); - - // reset override toggle to false if shared runners are enabled - this.allowOverride = false; + onSharedRunnersToggle(value) { + const newSetting = value ? this.runnerEnabledValue : this.runnerDisabledValue; + this.updateSetting(newSetting); }, - override() { - this.updateRunnerSettings(this.generatePayload(this.disabledWithOverrideSetting)); + onOverrideToggle(value) { + const newSetting = value ? this.runnerAllowOverrideValue : this.runnerDisabledValue; + this.updateSetting(newSetting); }, - updateRunnerSettings: debounce(function debouncedUpdateRunnerSettings(setting) { + updateSetting(setting) { + if (this.isLoading) { + return; + } + this.isLoading = true; axios - .put(this.updatePath, setting) + .put(this.updatePath, { shared_runners_setting: setting }) .then(() => { - this.isLoading = false; + this.value = setting; }) .catch((error) => { const message = [ @@ -76,51 +67,52 @@ export default { ].join(' '); this.error = message; + }) + .finally(() => { + this.isLoading = false; }); - }, DEBOUNCE_TOGGLE_DELAY), + }, }, }; </script> <template> - <div ref="sharedRunnersForm"> - <gl-alert v-if="error" variant="danger" :dismissible="false">{{ error }}</gl-alert> + <div> + <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5"> + {{ error }} + </gl-alert> - <h4 class="gl-display-flex gl-align-items-center"> - {{ __('Set up shared runner availability') }} - <gl-loading-icon v-if="isLoading" class="gl-ml-3" size="sm" inline /> - </h4> + <gl-alert + v-if="isSharedRunnersToggleDisabled" + variant="warning" + :dismissible="false" + class="gl-mb-5" + > + {{ __('Shared runners are disabled for the parent group') }} + </gl-alert> - <section class="gl-mt-5"> + <section class="gl-mb-5"> <gl-toggle - v-model="enabled" - :disabled="toggleDisabled" + :value="sharedRunnersToggleValue" + :is-loading="isLoading" + :disabled="isSharedRunnersToggleDisabled" :label="__('Enable shared runners for this group')" - data-testid="enable-runners-toggle" - @change="enableOrDisable" + :help="__('Enable shared runners for all projects and subgroups in this group.')" + data-testid="shared-runners-toggle" + @change="onSharedRunnersToggle" /> - - <span class="gl-text-gray-600"> - {{ __('Enable shared runners for all projects and subgroups in this group.') }} - </span> </section> - <section v-if="!enabled" class="gl-mt-5"> + <section class="gl-mb-5"> <gl-toggle - v-model="allowOverride" - :disabled="toggleDisabled" + :value="overrideToggleValue" + :is-loading="isLoading" + :disabled="isOverrideToggleDisabled" :label="__('Allow projects and subgroups to override the group setting')" + :help="__('Allows projects or subgroups in this group to override the global setting.')" data-testid="override-runners-toggle" - @change="override" + @change="onOverrideToggle" /> - - <span class="gl-text-gray-600"> - {{ __('Allows projects or subgroups in this group to override the global setting.') }} - </span> </section> - - <gl-tooltip v-if="toggleDisabled" :target="() => $refs.sharedRunnersForm"> - {{ __('Shared runners are disabled for the parent group') }} - </gl-tooltip> </div> </template> diff --git a/app/assets/javascripts/group_settings/constants.js b/app/assets/javascripts/group_settings/constants.js index 4067b6b52a3..ab5c0db45ba 100644 --- a/app/assets/javascripts/group_settings/constants.js +++ b/app/assets/javascripts/group_settings/constants.js @@ -1,6 +1,3 @@ import { __ } from '~/locale'; -// Debounce delay in milliseconds -export const DEBOUNCE_TOGGLE_DELAY = 1000; - export const ERROR_MESSAGE = __('Refresh the page and try again.'); diff --git a/app/assets/javascripts/group_settings/mount_shared_runners.js b/app/assets/javascripts/group_settings/mount_shared_runners.js index 21a2373e2b1..aeb6d57a11a 100644 --- a/app/assets/javascripts/group_settings/mount_shared_runners.js +++ b/app/assets/javascripts/group_settings/mount_shared_runners.js @@ -6,22 +6,22 @@ export default (containerId = 'update-shared-runners-form') => { const { updatePath, - sharedRunnersAvailability, - parentSharedRunnersAvailability, - runnerEnabled, - runnerDisabled, - runnerAllowOverride, + sharedRunnersSetting, + parentSharedRunnersSetting, + runnerEnabledValue, + runnerDisabledValue, + runnerAllowOverrideValue, } = containerEl.dataset; return new Vue({ el: containerEl, provide: { updatePath, - sharedRunnersAvailability, - parentSharedRunnersAvailability, - runnerEnabled, - runnerDisabled, - runnerAllowOverride, + sharedRunnersSetting, + parentSharedRunnersSetting, + runnerEnabledValue, + runnerDisabledValue, + runnerAllowOverrideValue, }, render(createElement) { return createElement(UpdateSharedRunnersForm); diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index 4406cacdf3f..adf304aebc7 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -120,9 +120,11 @@ export default { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), openDropdown() { this.showDropdown = true; + this.$emit('toggleDropdown', this.showDropdown); }, closeDropdown() { this.showDropdown = false; + this.$emit('toggleDropdown', this.showDropdown); }, submitSearch() { return visitUrl(this.currentFocusedOption?.url || this.searchQuery); @@ -146,7 +148,7 @@ export default { v-outside="closeDropdown" role="search" :aria-label="$options.i18n.searchGitlab" - class="header-search gl-relative gl-rounded-base" + class="header-search gl-relative gl-rounded-base gl-w-full" :class="headerSearchActivityDescriptor" > <gl-search-box-by-type diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js index 4af8513ecdb..b2c505d569f 100644 --- a/app/assets/javascripts/header_search/index.js +++ b/app/assets/javascripts/header_search/index.js @@ -7,6 +7,7 @@ Vue.use(Translate); export const initHeaderSearchApp = (search = '') => { const el = document.getElementById('js-header-search'); + let navBarEl = null; if (!el) { return false; @@ -19,8 +20,21 @@ export const initHeaderSearchApp = (search = '') => { return new Vue({ el, store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }), + mounted() { + navBarEl = document.querySelector('.header-content'); + }, render(createElement) { - return createElement(HeaderSearchApp); + return createElement(HeaderSearchApp, { + on: { + toggleDropdown: (isVisible = false) => { + if (isVisible) { + navBarEl?.classList.add('header-search-is-active'); + } else { + navBarEl?.classList.remove('header-search-is-active'); + } + }, + }, + }); }, }); }; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 38f3b094b7c..cb906374fe1 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -216,7 +216,9 @@ export default { modal-id="ide-commit-error-modal" :title="lastCommitError.title" :action-primary="commitErrorPrimaryAction.button" - :action-cancel="{ text: __('Cancel') }" + :action-cancel="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + text: __('Cancel'), + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @ok="commitErrorPrimaryAction.callback" > <div v-safe-html="lastCommitError.messageHTML"></div> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 32f87cb0a92..93ff7e8566f 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -108,7 +108,7 @@ export default { by <user-avatar-image css-classes="ide-status-avatar" - :size="18" + :size="16" :img-src="latestPipeline && latestPipeline.commit.author_gravatar_url" :img-alt="lastCommit.author_name" :tooltip-text="lastCommit.author_name" diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index 9262a4e1e95..5455a034106 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -44,6 +44,11 @@ const STATUS_MAP = { text: __('Failed'), variant: 'danger', }, + [STATUSES.TIMEOUT]: { + icon: 'status-failed', + text: __('Timeout'), + variant: 'danger', + }, [STATUSES.CANCELLED]: { icon: 'status-stopped', text: __('Cancelled'), diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js index 20a4d2d84b4..c470da21765 100644 --- a/app/assets/javascripts/import_entities/constants.js +++ b/app/assets/javascripts/import_entities/constants.js @@ -10,4 +10,5 @@ export const STATUSES = { NONE: 'none', SCHEDULING: 'scheduling', CANCELLED: 'cancelled', + TIMEOUT: 'timeout', }; 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 028197ec9b1..ce401862cc1 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 @@ -25,7 +25,7 @@ import importGroupsMutation from '../graphql/mutations/import_groups.mutation.gr import updateImportStatusMutation from '../graphql/mutations/update_import_status.mutation.graphql'; import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql'; import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; -import { NEW_NAME_FIELD, i18n } from '../constants'; +import { NEW_NAME_FIELD, ROOT_NAMESPACE, i18n } from '../constants'; import { StatusPoller } from '../services/status_poller'; import { isFinished, isAvailableForImport, isNameValid, isSameTarget } from '../utils'; import ImportActionsCell from './import_actions_cell.vue'; @@ -71,6 +71,10 @@ export default { type: String, required: true, }, + historyPath: { + type: String, + required: true, + }, }, data() { @@ -426,10 +430,10 @@ export default { return this.importTargets[group.id]; } - const defaultTargetNamespace = this.availableNamespaces[0] ?? { fullPath: '', id: null }; + const defaultTargetNamespace = this.availableNamespaces[0] ?? ROOT_NAMESPACE; let importTarget; if (group.lastImportTarget) { - const targetNamespace = this.availableNamespaces.find( + const targetNamespace = [ROOT_NAMESPACE, ...this.availableNamespaces].find( (ns) => ns.fullPath === group.lastImportTarget.targetNamespace, ); @@ -485,12 +489,15 @@ export default { <template> <div> - <h1 - class="gl-my-0 gl-py-4 gl-font-size-h1 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex" + <div + class="gl-display-flex gl-align-items-center gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1" > - <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" /> - {{ s__('BulkImport|Import groups from GitLab') }} - </h1> + <h1 class="gl-my-0 gl-py-4 gl-font-size-h1gl-display-flex"> + <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" /> + {{ s__('BulkImport|Import groups from GitLab') }} + </h1> + <gl-link :href="historyPath" class="gl-ml-auto">{{ s__('BulkImport|History') }}</gl-link> + </div> <gl-alert v-if="unavailableFeatures.length > 0 && unavailableFeaturesAlertVisible" variant="warning" diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue index 344a6e45370..4fbbd5b239c 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue @@ -57,6 +57,7 @@ export default { toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" class="gl-h-7 gl-flex-grow-1" data-qa-selector="target_namespace_selector_dropdown" + data-testid="target-namespace-selector" > <gl-dropdown-item @click="$emit('update-target-namespace', { fullPath: '', id: null })">{{ s__('BulkImport|No parent') diff --git a/app/assets/javascripts/import_entities/import_groups/constants.js b/app/assets/javascripts/import_entities/import_groups/constants.js index ac1466238d0..32137308684 100644 --- a/app/assets/javascripts/import_entities/import_groups/constants.js +++ b/app/assets/javascripts/import_entities/import_groups/constants.js @@ -18,3 +18,5 @@ export const i18n = { }; export const NEW_NAME_FIELD = 'newName'; + +export const ROOT_NAMESPACE = { fullPath: '', id: null }; diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js index 67a7258d504..02af0db7f9a 100644 --- a/app/assets/javascripts/import_entities/import_groups/index.js +++ b/app/assets/javascripts/import_entities/import_groups/index.js @@ -17,6 +17,7 @@ export function mountImportGroupsApp(mountElement) { jobsPath, sourceUrl, groupPathRegex, + historyPath, } = mountElement.dataset; const apolloProvider = new VueApollo({ defaultClient: createApolloClient({ @@ -38,6 +39,7 @@ export function mountImportGroupsApp(mountElement) { sourceUrl, jobsPath, groupPathRegex: new RegExp(`^(${groupPathRegex})$`), + historyPath, }, }); }, diff --git a/app/assets/javascripts/import_entities/import_groups/utils.js b/app/assets/javascripts/import_entities/import_groups/utils.js index 1d0ab75e1cb..f896203ba60 100644 --- a/app/assets/javascripts/import_entities/import_groups/utils.js +++ b/app/assets/javascripts/import_entities/import_groups/utils.js @@ -10,7 +10,7 @@ export function getInvalidNameValidationMessage(importTarget) { } export function isFinished(group) { - return [STATUSES.FINISHED, STATUSES.FAILED].includes(group.progress?.status); + return [STATUSES.FINISHED, STATUSES.FAILED, STATUSES.TIMEOUT].includes(group.progress?.status); } export function isAvailableForImport(group) { diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue index e0703a77424..0307607321e 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue @@ -1,5 +1,11 @@ <script> -import { GlButton, GlLoadingIcon, GlIntersectionObserver, GlModal, GlFormInput } from '@gitlab/ui'; +import { + GlButton, + GlLoadingIcon, + GlIntersectionObserver, + GlModal, + GlSearchBoxByClick, +} from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; import { n__, __, sprintf } from '~/locale'; import ProviderRepoTableRow from './provider_repo_table_row.vue'; @@ -12,7 +18,7 @@ export default { GlButton, GlModal, GlIntersectionObserver, - GlFormInput, + GlSearchBoxByClick, }, props: { providerTitle: { @@ -134,13 +140,13 @@ export default { <slot name="actions"></slot> <form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent> - <gl-form-input + <gl-search-box-by-click data-qa-selector="githubish_import_filter_field" name="filter" :placeholder="__('Filter by name')" autofocus - size="lg" - @keyup.enter="setFilter($event.target.value)" + @submit="setFilter" + @clear="setFilter('')" /> </form> </div> diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index bfc5bd823a2..922e870caa7 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -338,7 +338,9 @@ export default { :show-items="showList" :show-error-msg="showErrorMsg" :i18n="$options.i18n" - :items="incidents.list || []" + :items=" + incidents.list || [] /* eslint-disable-line @gitlab/vue-no-new-non-primitive-in-template */ + " :page-info="incidents.pageInfo" :items-count="incidentsCount" :status-tabs="$options.statusTabs" @@ -372,7 +374,10 @@ export default { <template #table> <gl-table - :items="incidents.list || []" + :items=" + incidents.list || + [] /* eslint-disable-line @gitlab/vue-no-new-non-primitive-in-template */ + " :fields="availableFields" :busy="loading" stacked="md" diff --git a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql index b72941966c6..2d84b141f32 100644 --- a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql +++ b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql @@ -1,4 +1,3 @@ -# eslint-disable-next-line @graphql-eslint/require-id-when-available fragment IncidentFields on Issue { severity escalationStatus diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index c5ed5bb08a9..b9975eed716 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -37,3 +37,13 @@ export const integrationFormSectionComponents = { [integrationFormSections.JIRA_TRIGGER]: 'IntegrationSectionJiraTrigger', [integrationFormSections.JIRA_ISSUES]: 'IntegrationSectionJiraIssues', }; + +export const billingPlans = { + PREMIUM: 'premium', + ULTIMATE: 'ultimate', +}; + +export const billingPlanNames = { + [billingPlans.PREMIUM]: s__('BillingPlans|Premium'), + [billingPlans.ULTIMATE]: s__('BillingPlans|Ultimate'), +}; diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 661299920c7..9f43360fb73 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -1,5 +1,11 @@ <script> -import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml, GlForm } from '@gitlab/ui'; +import { + GlBadge, + GlButton, + GlModalDirective, + GlSafeHtmlDirective as SafeHtml, + GlForm, +} from '@gitlab/ui'; import axios from 'axios'; import * as Sentry from '@sentry/browser'; import { mapState, mapActions, mapGetters } from 'vuex'; @@ -10,6 +16,7 @@ import { I18N_SUCCESSFUL_CONNECTION_MESSAGE, integrationLevels, integrationFormSectionComponents, + billingPlanNames, } from '~/integrations/constants'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import csrf from '~/lib/utils/csrf'; @@ -42,6 +49,7 @@ export default { import( /* webpackChunkName: 'integrationSectionJiraTrigger' */ '~/integrations/edit/components/sections/jira_trigger.vue' ), + GlBadge, GlButton, GlForm, }, @@ -177,6 +185,7 @@ export default { }, csrf, integrationFormSectionComponents, + billingPlanNames, }; </script> @@ -214,7 +223,20 @@ export default { > <div class="row"> <div class="col-lg-4"> - <h4 class="gl-mt-0">{{ section.title }}</h4> + <h4 class="gl-mt-0"> + {{ section.title + }}<gl-badge + v-if="section.plan" + :href="propsSource.aboutPricingUrl" + target="_blank" + rel="noopener noreferrer" + variant="tier" + icon="license" + class="gl-ml-3" + > + {{ $options.billingPlanNames[section.plan] }} + </gl-badge> + </h4> <p v-safe-html:[$options.descriptionHtmlConfig]="section.description"></p> </div> diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index f00339c92fa..584d23e17e1 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -2,7 +2,6 @@ import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { s__, __ } from '~/locale'; -import JiraUpgradeCta from './jira_upgrade_cta.vue'; export default { name: 'JiraIssuesFields', @@ -10,7 +9,6 @@ export default { GlFormGroup, GlFormCheckbox, GlFormInput, - JiraUpgradeCta, JiraIssueCreationVulnerabilities: () => import('ee_component/integrations/edit/components/jira_issue_creation_vulnerabilities.vue'), }, @@ -45,11 +43,6 @@ export default { required: false, default: null, }, - upgradePlanPath: { - type: String, - required: false, - default: '', - }, isValidated: { type: Boolean, required: false, @@ -64,6 +57,9 @@ export default { }, computed: { ...mapGetters(['isInheriting']), + checkboxDisabled() { + return !this.showJiraIssuesIntegration || this.isInheriting; + }, validProjectKey() { return !this.enableJiraIssues || Boolean(this.projectKey) || !this.isValidated; }, @@ -85,64 +81,48 @@ export default { <template> <div> - <template v-if="showJiraIssuesIntegration"> - <input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" /> - <gl-form-checkbox - v-model="enableJiraIssues" - :disabled="isInheriting" - data-qa-selector="service_jira_issues_enabled_checkbox" - > - {{ $options.i18n.enableCheckboxLabel }} - <template #help> - {{ $options.i18n.enableCheckboxHelp }} - </template> - </gl-form-checkbox> + <input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" /> + <gl-form-checkbox + v-model="enableJiraIssues" + :disabled="checkboxDisabled" + data-qa-selector="service_jira_issues_enabled_checkbox" + > + {{ $options.i18n.enableCheckboxLabel }} + <template #help> + {{ $options.i18n.enableCheckboxHelp }} + </template> + </gl-form-checkbox> - <div v-if="enableJiraIssues" class="gl-pl-6 gl-mt-3"> - <gl-form-group - :label="$options.i18n.projectKeyLabel" - label-for="service_project_key" - :invalid-feedback="$options.i18n.requiredFieldFeedback" + <div v-if="enableJiraIssues" class="gl-pl-6 gl-mt-3"> + <gl-form-group + :label="$options.i18n.projectKeyLabel" + label-for="service_project_key" + :invalid-feedback="$options.i18n.requiredFieldFeedback" + :state="validProjectKey" + class="gl-max-w-26" + data-testid="project-key-form-group" + > + <gl-form-input + id="service_project_key" + v-model="projectKey" + name="service[project_key]" + data-qa-selector="service_jira_project_key_field" + :placeholder="$options.i18n.projectKeyPlaceholder" + :required="enableJiraIssues" :state="validProjectKey" - class="gl-max-w-26" - data-testid="project-key-form-group" - > - <gl-form-input - id="service_project_key" - v-model="projectKey" - name="service[project_key]" - data-qa-selector="service_jira_project_key_field" - :placeholder="$options.i18n.projectKeyPlaceholder" - :required="enableJiraIssues" - :state="validProjectKey" - :readonly="isInheriting" - /> - </gl-form-group> - - <jira-issue-creation-vulnerabilities - :project-key="projectKey" - :initial-is-enabled="initialEnableJiraVulnerabilities" - :initial-issue-type-id="initialVulnerabilitiesIssuetype" - :show-full-feature="showJiraVulnerabilitiesIntegration" - class="gl-mt-6" - data-testid="jira-for-vulnerabilities" - @request-jira-issue-types="$emit('request-jira-issue-types')" - /> - <jira-upgrade-cta - v-if="!showJiraVulnerabilitiesIntegration" - class="gl-mt-2 gl-ml-6" - data-testid="ultimate-upgrade-cta" - show-ultimate-message - :upgrade-plan-path="upgradePlanPath" + :readonly="isInheriting" /> - </div> - </template> + </gl-form-group> - <jira-upgrade-cta - v-else - data-testid="premium-upgrade-cta" - show-premium-message - :upgrade-plan-path="upgradePlanPath" - /> + <jira-issue-creation-vulnerabilities + :project-key="projectKey" + :initial-is-enabled="initialEnableJiraVulnerabilities" + :initial-issue-type-id="initialVulnerabilitiesIssuetype" + :show-full-feature="showJiraVulnerabilitiesIntegration" + class="gl-mt-6" + data-testid="jira-for-vulnerabilities" + @request-jira-issue-types="$emit('request-jira-issue-types')" + /> + </div> </div> </template> diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index 9a9aae36657..92e6ca509c3 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -23,6 +23,7 @@ function parseDatasetToProps(data) { projectKey, upgradePlanPath, learnMorePath, + aboutPricingUrl, triggerEvents, sections, fields, @@ -82,6 +83,7 @@ function parseDatasetToProps(data) { upgradePlanPath, }, learnMorePath, + aboutPricingUrl, triggerEvents: JSON.parse(triggerEvents), sections: JSON.parse(sections, { deep: true }), fields: convertObjectPropsToCamelCase(JSON.parse(fields), { deep: true }), diff --git a/app/assets/javascripts/invite_members/components/import_a_project_modal.vue b/app/assets/javascripts/invite_members/components/import_a_project_modal.vue index d71468284ca..fb6c376cfe6 100644 --- a/app/assets/javascripts/invite_members/components/import_a_project_modal.vue +++ b/app/assets/javascripts/invite_members/components/import_a_project_modal.vue @@ -145,7 +145,7 @@ export default { <gl-button :disabled="importDisabled" :loading="isLoading" - variant="success" + variant="confirm" data-testid="import-button" @click="submitImport" >{{ $options.i18n.modalPrimaryButton }}</gl-button 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 a9aa0e9b760..7857b9d86d2 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -88,6 +88,11 @@ export default { type: Array, required: true, }, + usersLimitDataset: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { return { @@ -146,6 +151,18 @@ export default { isOnLearnGitlab() { return this.source === LEARN_GITLAB; }, + reachedLimit() { + if (this.usersLimitDataset.freeUsersLimit && this.usersLimitDataset.membersCount) { + return this.usersLimitDataset.membersCount >= this.usersLimitDataset.freeUsersLimit; + } + + return false; + }, + formGroupDescription() { + return this.reachedLimit + ? this.$options.labels.placeHolderDisabled + : this.$options.labels.placeHolder; + }, }, mounted() { eventHub.$on('openModal', (options) => { @@ -274,12 +291,14 @@ export default { :help-link="helpLink" :label-intro-text="labelIntroText" :label-search-field="$options.labels.searchField" - :form-group-description="$options.labels.placeHolder" + :form-group-description="formGroupDescription" :submit-disabled="inviteDisabled" :invalid-feedback-message="invalidFeedbackMessage" :is-loading="isLoading" :new-users-to-invite="newUsersToInvite" :root-group-id="rootId" + :reached-limit="reachedLimit" + :users-limit-dataset="usersLimitDataset" @reset="resetFields" @submit="sendInvite" @access-level="onAccessLevelUpdate" @@ -294,7 +313,10 @@ export default { </template> <template #user-limit-notification> - <user-limit-notification /> + <user-limit-notification + :reached-limit="reachedLimit" + :users-limit-dataset="usersLimitDataset" + /> </template> <template #select="{ validationState, labelId }"> diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue index d9297614a7e..33d37b809c2 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -8,7 +8,9 @@ import { GlLink, GlSprintf, GlFormInput, + GlIcon, } from '@gitlab/ui'; +import Tracking from '~/tracking'; import { sprintf } from '~/locale'; import ContentTransition from '~/vue_shared/components/content_transition.vue'; import { @@ -16,8 +18,13 @@ import { ACCESS_EXPIRE_DATE, READ_MORE_TEXT, INVITE_BUTTON_TEXT, + INVITE_BUTTON_TEXT_DISABLED, CANCEL_BUTTON_TEXT, + CANCEL_BUTTON_TEXT_DISABLED, HEADER_CLOSE_LABEL, + ON_SHOW_TRACK_LABEL, + ON_CLOSE_TRACK_LABEL, + ON_SUBMIT_TRACK_LABEL, } from '../constants'; const DEFAULT_SLOT = 'default'; @@ -41,8 +48,10 @@ export default { GlDropdownItem, GlSprintf, GlFormInput, + GlIcon, ContentTransition, }, + mixins: [Tracking.mixin()], inheritAttrs: false, props: { modalTitle: { @@ -122,6 +131,16 @@ export default { required: false, default: false, }, + reachedLimit: { + type: Boolean, + required: false, + default: false, + }, + usersLimitDataset: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { // Be sure to check out reset! @@ -151,20 +170,27 @@ export default { }, actionPrimary() { return { - text: this.submitButtonText, + text: this.reachedLimit ? INVITE_BUTTON_TEXT_DISABLED : this.submitButtonText, attributes: { variant: 'confirm', - disabled: this.submitDisabled, - loading: this.isLoading, + disabled: this.reachedLimit ? false : this.submitDisabled, + loading: this.reachedLimit ? false : this.isLoading, 'data-qa-selector': 'invite_button', + ...(this.reachedLimit && { href: this.usersLimitDataset.membersPath }), }, }; }, actionCancel() { + if (this.reachedLimit && this.usersLimitDataset.userNamespace) return undefined; + return { - text: this.cancelButtonText, + text: this.reachedLimit ? CANCEL_BUTTON_TEXT_DISABLED : this.cancelButtonText, + ...(this.reachedLimit && { attributes: { href: this.usersLimitDataset.purchasePath } }), }; }, + selectLabelClass() { + return `col-form-label ${this.reachedLimit ? 'gl-text-gray-500' : ''}`; + }, }, watch: { selectedAccessLevel: { @@ -183,15 +209,24 @@ export default { this.$emit('reset'); }, + onShowModal() { + if (this.reachedLimit) { + this.track('render', { category: 'default', label: ON_SHOW_TRACK_LABEL }); + } + }, onCloseModal(e) { - if (this.preventCancelDefault) { + if (this.preventCancelDefault || this.reachedLimit) { e.preventDefault(); } else { this.onReset(); this.$refs.modal.hide(); } - this.$emit('cancel'); + if (this.reachedLimit) { + this.track('click_button', { category: 'default', label: ON_CLOSE_TRACK_LABEL }); + } else { + this.$emit('cancel'); + } }, changeSelectedItem(item) { this.selectedAccessLevel = item; @@ -200,10 +235,14 @@ export default { // We never want to hide when submitting e.preventDefault(); - this.$emit('submit', { - accessLevel: this.selectedAccessLevel, - expiresAt: this.selectedDate, - }); + if (this.reachedLimit) { + this.track('click_button', { category: 'default', label: ON_SUBMIT_TRACK_LABEL }); + } else { + this.$emit('submit', { + accessLevel: this.selectedAccessLevel, + expiresAt: this.selectedDate, + }); + } }, }, HEADER_CLOSE_LABEL, @@ -227,6 +266,7 @@ export default { :header-close-label="$options.HEADER_CLOSE_LABEL" :action-primary="actionPrimary" :action-cancel="actionCancel" + @shown="onShowModal" @primary="onSubmit" @cancel="onCloseModal" @hidden="onReset" @@ -255,64 +295,73 @@ export default { <gl-form-group :invalid-feedback="invalidFeedbackMessage" :state="validationState" - :description="formGroupDescription" data-testid="members-form-group" > - <label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label> - <slot name="select" v-bind="{ validationState, labelId: selectLabelId }"></slot> + <template #description> + <gl-icon v-if="reachedLimit" name="lock" /> + {{ formGroupDescription }} + </template> + + <label :id="selectLabelId" :class="selectLabelClass">{{ labelSearchField }}</label> + <gl-form-input v-if="reachedLimit" data-testid="disabled-input" disabled /> + <slot v-else name="select" v-bind="{ validationState, labelId: selectLabelId }"></slot> </gl-form-group> - <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label> - <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-dropdown - class="gl-shadow-none gl-w-full" - data-qa-selector="access_level_dropdown" - v-bind="$attrs" - :text="selectedRoleName" - > - <template v-for="(key, item) in accessLevels"> - <gl-dropdown-item - :key="key" - active-class="is-active" - is-check-item - :is-checked="key === selectedAccessLevel" - @click="changeSelectedItem(key)" - > - <div>{{ item }}</div> - </gl-dropdown-item> - </template> - </gl-dropdown> - </div> + <template v-if="!reachedLimit"> + <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label> - <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-sprintf :message="$options.READ_MORE_TEXT"> - <template #link="{ content }"> - <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </div> + <div class="gl-mt-2 gl-w-half gl-xs-w-full"> + <gl-dropdown + class="gl-shadow-none gl-w-full" + data-qa-selector="access_level_dropdown" + v-bind="$attrs" + :text="selectedRoleName" + > + <template v-for="(key, item) in accessLevels"> + <gl-dropdown-item + :key="key" + active-class="is-active" + is-check-item + :is-checked="key === selectedAccessLevel" + @click="changeSelectedItem(key)" + > + <div>{{ item }}</div> + </gl-dropdown-item> + </template> + </gl-dropdown> + </div> - <label class="gl-mt-5 gl-display-block" for="expires_at">{{ - $options.ACCESS_EXPIRE_DATE - }}</label> - <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> - <gl-datepicker - v-model="selectedDate" - class="gl-display-inline!" - :min-date="minDate" - :target="null" - > - <template #default="{ formattedDate }"> - <gl-form-input - class="gl-w-full" - :value="formattedDate" - :placeholder="__(`YYYY-MM-DD`)" - /> - </template> - </gl-datepicker> - </div> - <slot name="form-after"></slot> + <div class="gl-mt-2 gl-w-half gl-xs-w-full"> + <gl-sprintf :message="$options.READ_MORE_TEXT"> + <template #link="{ content }"> + <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + + <label class="gl-mt-5 gl-display-block" for="expires_at">{{ + $options.ACCESS_EXPIRE_DATE + }}</label> + <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> + <gl-datepicker + v-model="selectedDate" + class="gl-display-inline!" + :min-date="minDate" + :target="null" + > + <template #default="{ formattedDate }"> + <gl-form-input + class="gl-w-full" + :value="formattedDate" + :placeholder="__(`YYYY-MM-DD`)" + /> + </template> + </gl-datepicker> + </div> + <slot name="form-after"></slot> + </template> </template> + <template v-for="{ key } in extraSlots" #[key]> <slot :name="key"></slot> </template> 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 0a191f6d406..30c9294344e 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -134,10 +134,10 @@ export default { :hide-dropdown-with-no-items="hideDropdownWithNoItems" :placeholder="placeholderText" :aria-labelledby="ariaLabelledby" - :text-input-attrs="{ + :text-input-attrs="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { 'data-testid': 'members-token-select-input', 'data-qa-selector': 'members_token_select_input', - }" + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @blur="handleBlur" @text-input="handleTextInput" @input="handleInput" diff --git a/app/assets/javascripts/invite_members/components/user_limit_notification.vue b/app/assets/javascripts/invite_members/components/user_limit_notification.vue index beef1aef8a1..ea5f4317d86 100644 --- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue +++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue @@ -1,35 +1,51 @@ <script> import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { s__, n__, sprintf } from '~/locale'; +import { n__, sprintf } from '~/locale'; -const CLOSE_TO_LIMIT_COUNT = 2; - -const WARNING_ALERT_TITLE = s__( - 'InviteMembersModal|You only have space for %{count} more %{members} in %{name}', -); - -const DANGER_ALERT_TITLE = s__( - "InviteMembersModal|You've reached your %{count} %{members} limit for %{name}", -); - -const CLOSE_TO_LIMIT_MESSAGE = s__( - 'InviteMembersModal|To get more members an owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.', -); +import { + WARNING_ALERT_TITLE, + DANGER_ALERT_TITLE, + REACHED_LIMIT_MESSAGE, + REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE, + CLOSE_TO_LIMIT_MESSAGE, +} from '../constants'; -const REACHED_LIMIT_MESSAGE = s__( - 'InviteMembersModal|New members will be unable to participate. You can manage your members by removing ones you no longer need.', -).concat(' ', CLOSE_TO_LIMIT_MESSAGE); +const CLOSE_TO_LIMIT_COUNT = 2; export default { name: 'UserLimitNotification', components: { GlAlert, GlSprintf, GlLink }, - inject: ['name', 'newTrialRegistrationPath', 'purchasePath', 'freeUsersLimit', 'membersCount'], + inject: ['name'], + props: { + reachedLimit: { + type: Boolean, + required: true, + }, + usersLimitDataset: { + type: Object, + required: false, + default: () => ({}), + }, + }, computed: { - reachedLimit() { - return this.isLimit(); + freeUsersLimit() { + return this.usersLimitDataset.freeUsersLimit; + }, + membersCount() { + return this.usersLimitDataset.membersCount; + }, + newTrialRegistrationPath() { + return this.usersLimitDataset.newTrialRegistrationPath; + }, + purchasePath() { + return this.usersLimitDataset.purchasePath; }, closeToLimit() { - return this.isLimit(CLOSE_TO_LIMIT_COUNT); + if (this.freeUsersLimit && this.membersCount) { + return this.membersCount >= this.freeUsersLimit - CLOSE_TO_LIMIT_COUNT; + } + + return false; }, warningAlertTitle() { return sprintf(WARNING_ALERT_TITLE, { @@ -51,28 +67,29 @@ export default { title() { return this.reachedLimit ? this.dangerAlertTitle : this.warningAlertTitle; }, + reachedLimitMessage() { + if (this.usersLimitDataset.userNamespace) { + return this.$options.i18n.reachedLimitMessage; + } + + return this.$options.i18n.reachedLimitUpgradeSuggestionMessage; + }, message() { if (this.reachedLimit) { - return this.$options.i18n.reachedLimitMessage; + return this.reachedLimitMessage; } return this.$options.i18n.closeToLimitMessage; }, }, methods: { - isLimit(deviation = 0) { - if (this.freeUsersLimit && this.membersCount) { - return this.membersCount >= this.freeUsersLimit - deviation; - } - - return false; - }, pluralMembers(count) { return n__('member', 'members', count); }, }, i18n: { reachedLimitMessage: REACHED_LIMIT_MESSAGE, + reachedLimitUpgradeSuggestionMessage: REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE, closeToLimitMessage: CLOSE_TO_LIMIT_MESSAGE, }, }; diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 3cd0bfc0181..928f79f1c8d 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -35,8 +35,11 @@ export const MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT = s__( export const MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT = s__( "InviteMembersModal|Congratulations on creating your project, you're almost there!", ); -export const MEMBERS_SEARCH_FIELD = s__('InviteMembersModal|GitLab member or email address'); +export const MEMBERS_SEARCH_FIELD = s__('InviteMembersModal|Username or email address'); export const MEMBERS_PLACEHOLDER = s__('InviteMembersModal|Select members or type email addresses'); +export const MEMBERS_PLACEHOLDER_DISABLED = s__( + 'InviteMembersModal|This feature is disabled until this group has space for more members.', +); export const MEMBERS_TASKS_TO_BE_DONE_TITLE = s__( 'InviteMembersModal|Create issues for your new team member to work on (optional)', ); @@ -66,7 +69,9 @@ export const READ_MORE_TEXT = s__( `InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`, ); export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite'); +export const INVITE_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Manage members'); export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel'); +export const CANCEL_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Explore paid plans'); export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members'); export const MEMBER_MODAL_LABELS = { @@ -94,6 +99,7 @@ export const MEMBER_MODAL_LABELS = { }, searchField: MEMBERS_SEARCH_FIELD, placeHolder: MEMBERS_PLACEHOLDER, + placeHolderDisabled: MEMBERS_PLACEHOLDER_DISABLED, tasksToBeDone: { title: MEMBERS_TASKS_TO_BE_DONE_TITLE, noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS, @@ -118,3 +124,27 @@ export const GROUP_MODAL_LABELS = { }; export const LEARN_GITLAB = 'learn_gitlab'; +export const ON_SHOW_TRACK_LABEL = 'locked_modal_viewed'; +export const ON_CLOSE_TRACK_LABEL = 'explore_paid_plans_clicked'; +export const ON_SUBMIT_TRACK_LABEL = 'manage_members_clicked'; + +export const WARNING_ALERT_TITLE = s__( + 'InviteMembersModal|You only have space for %{count} more %{members} in %{name}', +); +export const DANGER_ALERT_TITLE = s__( + "InviteMembersModal|You've reached your %{count} %{members} limit for %{name}", +); + +export const REACHED_LIMIT_MESSAGE = s__( + 'InviteMembersModal|You cannot add more members, but you can remove members who no longer need access.', +); + +export const REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE = REACHED_LIMIT_MESSAGE.concat( + s__( + 'InviteMembersModal| To get more members and access to additional paid features, an owner of this namespace can start a trial or upgrade to a paid tier.', + ), +); + +export const CLOSE_TO_LIMIT_MESSAGE = s__( + 'InviteMembersModal|To get more members an owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.', +); diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index 958121ad735..a4be3f205a3 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -1,7 +1,7 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; Vue.use(GlToast); @@ -26,10 +26,6 @@ export default (function initInviteMembersModal() { provide: { name: el.dataset.name, newProjectPath: el.dataset.newProjectPath, - newTrialRegistrationPath: el.dataset.newTrialRegistrationPath, - purchasePath: el.dataset.purchasePath, - freeUsersLimit: el.dataset.freeUsersLimit && parseInt(el.dataset.freeUsersLimit, 10), - membersCount: el.dataset.membersCount && parseInt(el.dataset.membersCount, 10), }, render: (createElement) => createElement(InviteMembersModal, { @@ -42,6 +38,9 @@ export default (function initInviteMembersModal() { projects: JSON.parse(el.dataset.projects || '[]'), usersFilter: el.dataset.usersFilter, filterId: parseInt(el.dataset.filterId, 10), + usersLimitDataset: convertObjectPropsToCamelCase( + JSON.parse(el.dataset.usersLimitDataset || '{}'), + ), }, }), }); diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue index 82223ab9ef4..06d1a2ee233 100644 --- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue +++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue @@ -2,14 +2,21 @@ import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { __ } from '~/locale'; +import { IssuableType, WorkspaceType } from '~/issues/constants'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; export default { + WorkspaceType, + IssuableType, components: { GlIcon, + ConfidentialityBadge, }, directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagMixin()], inject: ['hidden'], computed: { ...mapGetters(['getNoteableData']), @@ -19,6 +26,9 @@ export default { isConfidential() { return this.getNoteableData.confidential; }, + isMergeRequest() { + return this.getNoteableData.targetType === 'merge_request' && this.glFeatures.updatedMrHeader; + }, warningIconsMeta() { return [ { @@ -27,11 +37,6 @@ export default { dataTestId: 'locked', }, { - iconName: 'eye-slash', - visible: this.isConfidential, - dataTestId: 'confidential', - }, - { iconName: 'spam', visible: this.hidden, dataTestId: 'hidden', @@ -45,6 +50,12 @@ export default { <template> <div class="gl-display-inline-block"> + <confidentiality-badge + v-if="isConfidential" + data-testid="confidential" + :workspace-type="$options.WorkspaceType.project" + :issuable-type="$options.IssuableType.Issue" + /> <template v-for="meta in warningIconsMeta"> <div v-if="meta.visible" @@ -52,7 +63,11 @@ export default { v-gl-tooltip :data-testid="meta.dataTestId" :title="meta.tooltip || null" - class="issuable-warning-icon inline" + :class="{ + 'gl-mr-3 gl-mt-2 gl-display-flex gl-justify-content-center gl-align-items-center': isMergeRequest, + 'gl-display-inline-block': !isMergeRequest, + }" + class="issuable-warning-icon" > <gl-icon :name="meta.iconName" class="icon" /> </div> diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index 2bb0e3c80f9..dfe18567608 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -116,7 +116,7 @@ export default { <div class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2" > - <gl-tooltip :target="() => this.$refs.iconElement"> + <gl-tooltip :target="() => $refs.iconElement"> <span v-safe-html="stateTitle"></span> </gl-tooltip> <span v-gl-tooltip :title="itemPath" class="path-id-text d-inline-block">{{ diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue index bd6fdc131cb..498dc859186 100644 --- a/app/assets/javascripts/issuable/components/status_box.vue +++ b/app/assets/javascripts/issuable/components/status_box.vue @@ -1,32 +1,50 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlBadge, GlIcon } from '@gitlab/ui'; import Vue from 'vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { fetchPolicies } from '~/lib/graphql'; import { __ } from '~/locale'; +import { IssuableType } from '~/issues/constants'; +import { IssuableStates } from '~/vue_shared/issuable/list/constants'; -export const statusBoxState = Vue.observable({ +export const badgeState = Vue.observable({ state: '', updateStatus: null, }); const CLASSES = { - opened: 'status-box-open', - locked: 'status-box-open', - closed: 'status-box-mr-closed', - merged: 'status-box-mr-merged', + opened: 'issuable-status-badge-open', + locked: 'issuable-status-badge-open', + closed: 'issuable-status-badge-closed', + merged: 'issuable-status-badge-merged', +}; + +const ISSUE_ICONS = { + opened: 'issues', + locked: 'issues', + closed: 'issue-closed', +}; + +const MERGE_REQUEST_ICONS = { + opened: 'merge-request-open', + locked: 'merge-request-open', + closed: 'merge-request-close', + merged: 'merge', }; const STATUS = { - opened: [__('Open'), 'issue-open-m'], - locked: [__('Open'), 'issue-open-m'], - closed: [__('Closed'), 'issue-close'], - merged: [__('Merged'), 'git-merge'], + opened: __('Open'), + locked: __('Open'), + closed: __('Closed'), + merged: __('Merged'), }; export default { components: { + GlBadge, GlIcon, }, + mixins: [glFeatureFlagMixin()], inject: { query: { default: null }, projectPath: { default: null }, @@ -46,30 +64,47 @@ export default { }, data() { if (this.initialState) { - statusBoxState.state = this.initialState; + badgeState.state = this.initialState; } - return statusBoxState; + return badgeState; }, computed: { - statusBoxClass() { - return CLASSES[`${this.issuableType}_${this.state}`] || CLASSES[this.state]; + badgeClass() { + return [ + CLASSES[this.state], + { + 'gl-vertical-align-bottom': + this.issuableType === IssuableType.MergeRequest && this.glFeatures.updatedMrHeader, + }, + ]; + }, + badgeVariant() { + if (this.state === IssuableStates.Opened) { + return 'success'; + } else if (this.state === IssuableStates.Closed) { + return this.issuableType === IssuableType.MergeRequest ? 'danger' : 'info'; + } + return 'info'; }, - statusHumanName() { - return (STATUS[`${this.issuableType}_${this.state}`] || STATUS[this.state])[0]; + badgeText() { + return STATUS[this.state]; }, - statusIconName() { - return (STATUS[`${this.issuableType}_${this.state}`] || STATUS[this.state])[1]; + badgeIcon() { + if (this.issuableType === IssuableType.Issue) { + return ISSUE_ICONS[this.state]; + } + return MERGE_REQUEST_ICONS[this.state]; }, }, created() { - if (!statusBoxState.updateStatus) { - statusBoxState.updateStatus = this.fetchState; + if (!badgeState.updateStatus) { + badgeState.updateStatus = this.fetchState; } }, beforeDestroy() { - if (statusBoxState.updateStatus && this.query) { - statusBoxState.updateStatus = null; + if (badgeState.updateStatus && this.query) { + badgeState.updateStatus = null; } }, methods: { @@ -83,17 +118,15 @@ export default { fetchPolicy: fetchPolicies.NO_CACHE, }); - statusBoxState.state = data?.workspace?.issuable?.state; + badgeState.state = data?.workspace?.issuable?.state; }, }, }; </script> <template> - <div :class="statusBoxClass" class="issuable-status-box status-box"> - <gl-icon :name="statusIconName" class="gl-display-block gl-sm-display-none!" /> - <span class="gl-display-none gl-sm-display-block"> - {{ statusHumanName }} - </span> - </div> + <gl-badge class="issuable-status-badge gl-mr-3" :class="badgeClass" :variant="badgeVariant"> + <gl-icon :name="badgeIcon" /> + <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ badgeText }}</span> + </gl-badge> </template> diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index 018cadad50f..8e76a33c7dd 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -65,7 +65,8 @@ export default class IssuableForm { this.gfmAutoComplete = new GfmAutoComplete( gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources, ).setup(); - this.usersSelect = new UsersSelect(); + const autoAssignToMe = form.get(0).id === 'new_merge_request'; + this.usersSelect = new UsersSelect(undefined, undefined, { autoAssignToMe }); this.reviewersSelect = new UsersSelect(undefined, '.js-reviewer-search'); this.zenMode = new ZenMode(); diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js index c96af6da720..8294c018117 100644 --- a/app/assets/javascripts/issues/create_merge_request_dropdown.js +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -69,12 +69,12 @@ export default class CreateMergeRequestDropdown { // with user's inputs. this.regexps = { branch: { - createBranchPath: new RegExp('(branch_name=)(.+?)(?=&issue)'), - createMrPath: new RegExp('(source_branch%5D=)(.+?)(?=&)'), + createBranchPath: /(branch_name=)(.+?)(?=&issue)/, + createMrPath: /(source_branch%5D=)(.+?)(?=&)/, }, ref: { - createBranchPath: new RegExp('(ref=)(.+?)$'), - createMrPath: new RegExp('(target_branch%5D=)(.+?)$'), + createBranchPath: /(ref=)(.+?)$/, + createMrPath: /(target_branch%5D=)(.+?)$/, }, }; @@ -170,7 +170,6 @@ export default class CreateMergeRequestDropdown { createMergeRequest() { return new Promise(() => { this.isCreatingMergeRequest = true; - return this.createBranch().then(() => { let path = canCreateConfidentialMergeRequest() ? this.createMrPath.replace( diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js index 8e27f547b5c..a9321cf200d 100644 --- a/app/assets/javascripts/issues/issue.js +++ b/app/assets/javascripts/issues/issue.js @@ -49,8 +49,8 @@ export default class Issue { issueFailMessage = __('Unable to update this issue at this time.'), ) { if ('id' in data) { - const isClosedBadge = $('div.status-box-issue-closed'); - const isOpenBadge = $('div.status-box-open'); + const isClosedBadge = $('.issuable-status-badge-closed'); + const isOpenBadge = $('.issuable-status-badge-open'); const projectIssuesCounter = $('.issue_counter'); isClosedBadge.toggleClass('hidden', !isClosed); diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index a43aed6c521..b81ab103271 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -13,6 +13,8 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; +import getIssuesWithoutCrmQuery from 'ee_else_ce/issues/list/queries/get_issues_without_crm.query.graphql'; +import getIssuesCountsWithoutCrmQuery from 'ee_else_ce/issues/list/queries/get_issues_counts_without_crm.query.graphql'; import createFlash, { FLASH_TYPES } from '~/flash'; import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -45,6 +47,7 @@ import { PAGE_SIZE, PARAM_PAGE_AFTER, PARAM_PAGE_BEFORE, + PARAM_SORT, PARAM_STATE, RELATIVE_POSITION_ASC, TOKEN_TYPE_ASSIGNEE, @@ -122,6 +125,7 @@ export default { 'isAnonymousSearchDisabled', 'isIssueRepositioningDisabled', 'isProject', + 'isPublicVisibilityRestricted', 'isSignedIn', 'jiraIntegrationPath', 'newIssuePath', @@ -138,48 +142,24 @@ export default { }, }, data() { - const pageAfter = getParameterByName(PARAM_PAGE_AFTER); - const pageBefore = getParameterByName(PARAM_PAGE_BEFORE); - const state = getParameterByName(PARAM_STATE); - const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; - const dashboardSortKey = getSortKey(this.initialSort); - const graphQLSortKey = - isSortKey(this.initialSort?.toUpperCase()) && this.initialSort.toUpperCase(); - - // The initial sort is an old enum value when it is saved on the dashboard issues page. - // The initial sort is a GraphQL enum value when it is saved on the Vue issues list page. - let sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey; - - if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { - this.showIssueRepositioningMessage(); - sortKey = defaultSortKey; - } - - const isSearchDisabled = - this.isAnonymousSearchDisabled && - !this.isSignedIn && - window.location.search.includes('search='); - - if (isSearchDisabled) { - this.showAnonymousSearchingMessage(); - } - return { exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), - filterTokens: isSearchDisabled ? [] : getFilterTokens(window.location.search), + filterTokens: [], issues: [], issuesCounts: {}, issuesError: null, pageInfo: {}, - pageParams: getInitialPageParams(sortKey, pageAfter, pageBefore), + pageParams: {}, showBulkEditSidebar: false, - sortKey, - state: state || IssuableStates.Opened, + sortKey: CREATED_DESC, + state: IssuableStates.Opened, }; }, apollo: { issues: { - query: getIssuesQuery, + query() { + return this.hasCrmParameter ? getIssuesQuery : getIssuesWithoutCrmQuery; + }, variables() { return this.queryVariables; }, @@ -203,7 +183,9 @@ export default { debounce: 200, }, issuesCounts: { - query: getIssuesCountsQuery, + query() { + return this.hasCrmParameter ? getIssuesCountsQuery : getIssuesCountsWithoutCrmQuery; + }, variables() { return this.queryVariables; }, @@ -228,6 +210,7 @@ export default { const isIidSearch = ISSUE_REFERENCE.test(this.searchQuery); return { fullPath: this.fullPath, + hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn, iid: isIidSearch ? this.searchQuery.slice(1) : undefined, isProject: this.isProject, isSignedIn: this.isSignedIn, @@ -415,8 +398,22 @@ export default { ...this.urlFilterParams, }; }, + hasCrmParameter() { + return ( + window.location.search.includes('crm_contact_id=') || + window.location.search.includes('crm_organization_id=') + ); + }, + }, + watch: { + $route(newValue, oldValue) { + if (newValue.fullPath !== oldValue.fullPath) { + this.updateData(getParameterByName(PARAM_SORT)); + } + }, }, created() { + this.updateData(this.initialSort); this.cache = {}; }, mounted() { @@ -516,6 +513,8 @@ export default { this.pageParams = getInitialPageParams(this.sortKey); } this.state = state; + + this.$router.push({ query: this.urlParams }); }, handleDismissAlert() { this.issuesError = null; @@ -525,8 +524,11 @@ export default { this.showAnonymousSearchingMessage(); return; } + this.pageParams = getInitialPageParams(this.sortKey); this.filterTokens = filter; + + this.$router.push({ query: this.urlParams }); }, handleNextPage() { this.pageParams = { @@ -534,6 +536,8 @@ export default { firstPageSize: PAGE_SIZE, }; scrollUp(); + + this.$router.push({ query: this.urlParams }); }, handlePreviousPage() { this.pageParams = { @@ -541,6 +545,8 @@ export default { lastPageSize: PAGE_SIZE, }; scrollUp(); + + this.$router.push({ query: this.urlParams }); }, handleReorder({ newIndex, oldIndex }) { const issueToMove = this.issues[oldIndex]; @@ -592,6 +598,8 @@ export default { if (this.isSignedIn) { this.saveSortPreference(sortKey); } + + this.$router.push({ query: this.urlParams }); }, saveSortPreference(sortKey) { this.$apollo @@ -623,6 +631,39 @@ export default { toggleBulkEditSidebar(showBulkEditSidebar) { this.showBulkEditSidebar = showBulkEditSidebar; }, + updateData(sortValue) { + const pageAfter = getParameterByName(PARAM_PAGE_AFTER); + const pageBefore = getParameterByName(PARAM_PAGE_BEFORE); + const state = getParameterByName(PARAM_STATE); + + const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; + const dashboardSortKey = getSortKey(sortValue); + const graphQLSortKey = isSortKey(sortValue?.toUpperCase()) && sortValue.toUpperCase(); + + // The initial sort is an old enum value when it is saved on the dashboard issues page. + // The initial sort is a GraphQL enum value when it is saved on the Vue issues list page. + let sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey; + + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { + this.showIssueRepositioningMessage(); + sortKey = defaultSortKey; + } + + const isSearchDisabled = + this.isAnonymousSearchDisabled && + !this.isSignedIn && + window.location.search.includes('search='); + + if (isSearchDisabled) { + this.showAnonymousSearchingMessage(); + } + + this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); + this.filterTokens = isSearchDisabled ? [] : getFilterTokens(window.location.search); + this.pageParams = getInitialPageParams(sortKey, pageAfter, pageBefore); + this.sortKey = sortKey; + this.state = state || IssuableStates.Opened; + }, }, }; </script> @@ -649,10 +690,10 @@ export default { :is-manual-ordering="isManualOrdering" :show-bulk-edit-sidebar="showBulkEditSidebar" :show-pagination-controls="showPaginationControls" - :use-keyset-pagination="true" + sync-filter-and-sort + use-keyset-pagination :has-next-page="pageInfo.hasNextPage" :has-previous-page="pageInfo.hasPreviousPage" - :url-params="urlParams" @click-tab="handleClickTab" @dismiss-alert="handleDismissAlert" @filter="handleFilter" diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 4b07a078512..0795df10a7c 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -56,8 +56,10 @@ export const ISSUE_REFERENCE = /^#\d+$/; export const MAX_LIST_SIZE = 10; export const PAGE_SIZE = 20; export const PAGE_SIZE_MANUAL = 100; +export const PARAM_ASSIGNEE_ID = 'assignee_id'; export const PARAM_PAGE_AFTER = 'page_after'; export const PARAM_PAGE_BEFORE = 'page_before'; +export const PARAM_SORT = 'sort'; export const PARAM_STATE = 'state'; export const RELATIVE_POSITION = 'relative_position'; @@ -112,7 +114,8 @@ export const URL_PARAM = 'urlParam'; export const NORMAL_FILTER = 'normalFilter'; export const SPECIAL_FILTER = 'specialFilter'; export const ALTERNATIVE_FILTER = 'alternativeFilter'; -export const SPECIAL_FILTER_VALUES = [ + +export const specialFilterValues = [ FILTER_NONE, FILTER_ANY, FILTER_CURRENT, @@ -131,6 +134,8 @@ export const TOKEN_TYPE_CONFIDENTIAL = 'confidential'; export const TOKEN_TYPE_ITERATION = 'iteration'; export const TOKEN_TYPE_EPIC = 'epic_id'; export const TOKEN_TYPE_WEIGHT = 'weight'; +export const TOKEN_TYPE_CONTACT = 'crm_contact'; +export const TOKEN_TYPE_ORGANIZATION = 'crm_organization'; export const filters = { [TOKEN_TYPE_AUTHOR]: { @@ -174,6 +179,7 @@ export const filters = { }, [OPERATOR_IS_NOT]: { [NORMAL_FILTER]: 'not[milestone_title]', + [SPECIAL_FILTER]: 'not[milestone_title]', }, }, }, @@ -258,6 +264,7 @@ export const filters = { }, [OPERATOR_IS_NOT]: { [NORMAL_FILTER]: 'not[iteration_id]', + [SPECIAL_FILTER]: 'not[iteration_id]', }, }, }, @@ -291,4 +298,24 @@ export const filters = { }, }, }, + [TOKEN_TYPE_CONTACT]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'crmContactId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'crm_contact_id', + }, + }, + }, + [TOKEN_TYPE_ORGANIZATION]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'crmOrganizationId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'crm_organization_id', + }, + }, + }, }; diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index 3b2d37eab74..f5cb160e344 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -1,6 +1,7 @@ import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import IssuesListApp from 'ee_else_ce/issues/list/components/issues_list_app.vue'; import createDefaultClient from '~/lib/graphql'; @@ -53,6 +54,7 @@ export function mountIssuesListApp() { } Vue.use(VueApollo); + Vue.use(VueRouter); const resolvers = { Mutation: { @@ -74,11 +76,6 @@ export function mountIssuesListApp() { }, }; - const defaultClient = createDefaultClient(resolvers); - const apolloProvider = new VueApollo({ - defaultClient, - }); - const { autocompleteAwardEmojisPath, calendarPath, @@ -104,6 +101,7 @@ export function mountIssuesListApp() { isAnonymousSearchDisabled, isIssueRepositioningDisabled, isProject, + isPublicVisibilityRestricted, isSignedIn, jiraIntegrationPath, markdownHelpPath, @@ -121,7 +119,14 @@ export function mountIssuesListApp() { return new Vue({ el, name: 'IssuesListRoot', - apolloProvider, + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(resolvers), + }), + router: new VueRouter({ + base: window.location.pathname, + mode: 'history', + routes: [{ path: '/' }], + }), provide: { autocompleteAwardEmojisPath, calendarPath, @@ -140,6 +145,7 @@ export function mountIssuesListApp() { isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled), isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled), isProject: parseBoolean(isProject), + isPublicVisibilityRestricted: parseBoolean(isPublicVisibilityRestricted), isSignedIn: parseBoolean(isSignedIn), jiraIntegrationPath, newIssuePath, diff --git a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql index ec24ea7c56a..df7016aeb74 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql @@ -2,6 +2,7 @@ #import "./issue.fragment.graphql" query getIssues( + $hideUsers: Boolean = false $isProject: Boolean = false $isSignedIn: Boolean = false $fullPath: ID! @@ -20,6 +21,8 @@ query getIssues( $releaseTag: [String!] $releaseTagWildcardId: ReleaseTagWildcardId $types: [IssueType!] + $crmContactId: String + $crmOrganizationId: String $not: NegatedIssueFilterInput $beforeCursor: String $afterCursor: String @@ -43,6 +46,8 @@ query getIssues( milestoneWildcardId: $milestoneWildcardId myReactionEmoji: $myReactionEmoji types: $types + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId not: $not before: $beforeCursor after: $afterCursor @@ -76,6 +81,8 @@ query getIssues( releaseTag: $releaseTag releaseTagWildcardId: $releaseTagWildcardId types: $types + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId not: $not before: $beforeCursor after: $afterCursor diff --git a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql index 58e7ce32e7c..c1aee772167 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql @@ -14,6 +14,8 @@ query getIssuesCount( $releaseTag: [String!] $releaseTagWildcardId: ReleaseTagWildcardId $types: [IssueType!] + $crmContactId: String + $crmOrganizationId: String $not: NegatedIssueFilterInput ) { group(fullPath: $fullPath) @skip(if: $isProject) { @@ -32,6 +34,8 @@ query getIssuesCount( milestoneWildcardId: $milestoneWildcardId myReactionEmoji: $myReactionEmoji types: $types + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId not: $not ) { count @@ -50,6 +54,8 @@ query getIssuesCount( milestoneWildcardId: $milestoneWildcardId myReactionEmoji: $myReactionEmoji types: $types + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId not: $not ) { count @@ -68,6 +74,8 @@ query getIssuesCount( milestoneWildcardId: $milestoneWildcardId myReactionEmoji: $myReactionEmoji types: $types + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId not: $not ) { count @@ -90,6 +98,8 @@ query getIssuesCount( releaseTag: $releaseTag releaseTagWildcardId: $releaseTagWildcardId types: $types + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId not: $not ) { count @@ -109,6 +119,8 @@ query getIssuesCount( releaseTag: $releaseTag releaseTagWildcardId: $releaseTagWildcardId types: $types + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId not: $not ) { count @@ -128,6 +140,8 @@ query getIssuesCount( releaseTag: $releaseTag releaseTagWildcardId: $releaseTagWildcardId types: $types + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId not: $not ) { count diff --git a/app/assets/javascripts/issues/list/queries/get_issues_counts_without_crm.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_counts_without_crm.query.graphql new file mode 100644 index 00000000000..ab91aab1218 --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/get_issues_counts_without_crm.query.graphql @@ -0,0 +1,136 @@ +query getIssuesCountWithoutCrm( + $isProject: Boolean = false + $fullPath: ID! + $iid: String + $search: String + $assigneeId: String + $assigneeUsernames: [String!] + $authorUsername: String + $confidential: Boolean + $labelName: [String] + $milestoneTitle: [String] + $milestoneWildcardId: MilestoneWildcardId + $myReactionEmoji: String + $releaseTag: [String!] + $releaseTagWildcardId: ReleaseTagWildcardId + $types: [IssueType!] + $not: NegatedIssueFilterInput +) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + openedIssues: issues( + includeSubgroups: true + state: opened + iid: $iid + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + not: $not + ) { + count + } + closedIssues: issues( + includeSubgroups: true + state: closed + iid: $iid + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + not: $not + ) { + count + } + allIssues: issues( + includeSubgroups: true + state: all + iid: $iid + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + not: $not + ) { + count + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + openedIssues: issues( + state: opened + iid: $iid + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + not: $not + ) { + count + } + closedIssues: issues( + state: closed + iid: $iid + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + not: $not + ) { + count + } + allIssues: issues( + state: all + iid: $iid + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + not: $not + ) { + count + } + } +} diff --git a/app/assets/javascripts/issues/list/queries/get_issues_without_crm.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_without_crm.query.graphql new file mode 100644 index 00000000000..4a8b1dfd618 --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/get_issues_without_crm.query.graphql @@ -0,0 +1,94 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "./issue.fragment.graphql" + +query getIssuesWithoutCrm( + $hideUsers: Boolean = false + $isProject: Boolean = false + $isSignedIn: Boolean = false + $fullPath: ID! + $iid: String + $search: String + $sort: IssueSort + $state: IssuableState + $assigneeId: String + $assigneeUsernames: [String!] + $authorUsername: String + $confidential: Boolean + $labelName: [String] + $milestoneTitle: [String] + $milestoneWildcardId: MilestoneWildcardId + $myReactionEmoji: String + $releaseTag: [String!] + $releaseTagWildcardId: ReleaseTagWildcardId + $types: [IssueType!] + $not: NegatedIssueFilterInput + $beforeCursor: String + $afterCursor: String + $firstPageSize: Int + $lastPageSize: Int +) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + issues( + includeSubgroups: true + iid: $iid + search: $search + sort: $sort + state: $state + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + not: $not + before: $beforeCursor + after: $afterCursor + first: $firstPageSize + last: $lastPageSize + ) { + pageInfo { + ...PageInfo + } + nodes { + ...IssueFragment + reference(full: true) + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + issues( + iid: $iid + search: $search + sort: $sort + state: $state + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + not: $not + before: $beforeCursor + after: $afterCursor + first: $firstPageSize + last: $lastPageSize + ) { + pageInfo { + ...PageInfo + } + nodes { + ...IssueFragment + } + } + } +} diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql index d09e4d9df2b..73a13cea94a 100644 --- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql @@ -17,7 +17,7 @@ fragment IssueFragment on Issue { userDiscussionsCount @include(if: $isSignedIn) webPath webUrl - assignees { + assignees @skip(if: $hideUsers) { nodes { __typename id @@ -27,7 +27,7 @@ fragment IssueFragment on Issue { webUrl } } - author { + author @skip(if: $hideUsers) { __typename id avatarUrl diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index 4b77bd9bc5f..3ca93069628 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -1,4 +1,6 @@ +import { createTerm } from '@gitlab/ui/src/components/base/filtered_search/filtered_search_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { FILTERED_SEARCH_TERM, @@ -20,13 +22,14 @@ import { NORMAL_FILTER, PAGE_SIZE, PAGE_SIZE_MANUAL, + PARAM_ASSIGNEE_ID, POPULARITY_ASC, POPULARITY_DESC, PRIORITY_ASC, PRIORITY_DESC, RELATIVE_POSITION_ASC, SPECIAL_FILTER, - SPECIAL_FILTER_VALUES, + specialFilterValues, TITLE_ASC, TITLE_DESC, TOKEN_TYPE_ASSIGNEE, @@ -195,23 +198,27 @@ const convertToFilteredSearchTerms = (locationSearch) => export const getFilterTokens = (locationSearch) => { if (!locationSearch) { - return []; + return [createTerm()]; } const filterTokens = convertToFilteredTokens(locationSearch); const searchTokens = convertToFilteredSearchTerms(locationSearch); - return filterTokens.concat(searchTokens); + const tokens = filterTokens.concat(searchTokens); + return tokens.length ? tokens : [createTerm()]; }; -const getFilterType = (data, tokenType = '') => - SPECIAL_FILTER_VALUES.includes(data) || - (tokenType === TOKEN_TYPE_ASSIGNEE && isPositiveInteger(data)) - ? SPECIAL_FILTER - : NORMAL_FILTER; +const getFilterType = (data, tokenType = '') => { + const isAssigneeIdParam = + tokenType === TOKEN_TYPE_ASSIGNEE && + isPositiveInteger(data) && + getParameterByName(PARAM_ASSIGNEE_ID) === data; + + return specialFilterValues.includes(data) || isAssigneeIdParam ? SPECIAL_FILTER : NORMAL_FILTER; +}; const wildcardTokens = [TOKEN_TYPE_ITERATION, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_RELEASE]; const isWildcardValue = (tokenType, value) => - wildcardTokens.includes(tokenType) && SPECIAL_FILTER_VALUES.includes(value); + wildcardTokens.includes(tokenType) && specialFilterValues.includes(value); const requiresUpperCaseValue = (tokenType, value) => tokenType === TOKEN_TYPE_TYPE || isWildcardValue(tokenType, value); diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 456a2029703..c664135f30e 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -1,11 +1,17 @@ <script> -import { GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; import Visibility from 'visibilityjs'; import createFlash from '~/flash'; -import { IssuableStatus, IssuableStatusText, IssuableType } from '~/issues/constants'; +import { + IssuableStatus, + IssuableStatusText, + WorkspaceType, + IssuableType, +} from '~/issues/constants'; import Poll from '~/lib/utils/poll'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; import { ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH, INCIDENT_TYPE, POLLING_DELAY } from '../constants'; import eventHub from '../event_hub'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; @@ -18,13 +24,16 @@ import PinnedLinks from './pinned_links.vue'; import titleComponent from './title.vue'; export default { + WorkspaceType, components: { GlIcon, + GlBadge, GlIntersectionObserver, titleComponent, editedComponent, formComponent, PinnedLinks, + ConfidentialityBadge, }, directives: { GlTooltip: GlTooltipDirective, @@ -156,7 +165,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: IssuableType.Issue, }, canAttachFile: { type: Boolean, @@ -259,13 +268,19 @@ export default { : ''; }, statusIcon() { - return this.isClosed ? 'issue-close' : 'issue-open-m'; + if (this.issuableType === IssuableType.Issue) { + return this.isClosed ? 'issue-closed' : 'issues'; + } + return this.isClosed ? 'epic-closed' : 'epic'; + }, + statusVariant() { + return this.isClosed ? 'info' : 'success'; }, statusText() { return IssuableStatusText[this.issuableStatus]; }, shouldShowStickyHeader() { - return this.issuableType === IssuableType.Issue; + return [IssuableType.Issue, IssuableType.Epic].includes(this.issuableType); }, }, created() { @@ -327,20 +342,24 @@ export default { }); }, - updateFormState(state) { + setFormState(state) { this.store.setFormState(state); }, - updateAndShowForm(templates = {}) { + updateFormState(templates = {}) { + this.setFormState({ + title: this.state.titleText, + description: this.state.descriptionText, + lock_version: this.state.lock_version, + lockedWarningVisible: false, + updateLoading: false, + issuableTemplates: templates, + }); + }, + + updateAndShowForm(templates) { if (!this.showForm) { - this.store.setFormState({ - title: this.state.titleText, - description: this.state.descriptionText, - lock_version: this.state.lock_version, - lockedWarningVisible: false, - updateLoading: false, - issuableTemplates: templates, - }); + this.updateFormState(templates); this.showForm = true; } }, @@ -373,9 +392,7 @@ export default { }, updateIssuable() { - this.store.setFormState({ - updateLoading: true, - }); + this.setFormState({ updateLoading: true }); const { store: { formState }, @@ -413,10 +430,6 @@ export default { .catch((error = {}) => { const { message, response = {} } = error; - this.store.setFormState({ - updateLoading: false, - }); - let errMsg = this.defaultErrorMessage; if (response.data && response.data.errors) { @@ -428,6 +441,9 @@ export default { this.flashContainer = createFlash({ message: errMsg, }); + }) + .finally(() => { + this.setFormState({ updateLoading: false }); }); }, @@ -446,6 +462,12 @@ export default { } }, + handleListItemReorder(description) { + this.updateFormState(); + this.setFormState({ description }); + this.updateIssuable(); + }, + taskListUpdateStarted() { this.poll.stop(); }, @@ -483,7 +505,7 @@ export default { :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" :issuable-type="issuableType" - @updateForm="updateFormState" + @updateForm="setFormState" /> </div> <div v-else> @@ -509,19 +531,21 @@ export default { <div class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5" > - <p - class="issuable-status-box status-box gl-my-0" - :class="[isClosed ? 'status-box-issue-closed' : 'status-box-open']" + <gl-badge :variant="statusVariant" class="gl-mr-2"> + <gl-icon :name="statusIcon" /> + <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ + statusText + }}</span></gl-badge > - <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" /> - <span class="gl-display-none d-sm-block">{{ statusText }}</span> - </p> <span v-if="isLocked" data-testid="locked" class="issuable-warning-icon"> <gl-icon name="lock" :aria-label="__('Locked')" /> </span> - <span v-if="isConfidential" data-testid="confidential" class="issuable-warning-icon"> - <gl-icon name="eye-slash" :aria-label="__('Confidential')" /> - </span> + <confidentiality-badge + v-if="isConfidential" + data-testid="confidential" + :workspace-type="$options.WorkspaceType.project" + :issuable-type="issuableType" + /> <span v-if="isHidden" v-gl-tooltip @@ -559,6 +583,8 @@ export default { :issuable-type="issuableType" :update-url="updateEndpoint" :lock-version="state.lock_version" + :is-updating="formState.updateLoading" + @listItemReorder="handleListItemReorder" @taskListUpdateStarted="taskListUpdateStarted" @taskListUpdateSucceeded="taskListUpdateSucceeded" @taskListUpdateFailed="taskListUpdateFailed" diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 0b7e128c47b..4f97458dcd1 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -7,22 +7,31 @@ import { GlModalDirective, } from '@gitlab/ui'; import $ from 'jquery'; +import Sortable from 'sortablejs'; import Vue from 'vue'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; import createFlash from '~/flash'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; +import { getSortableDefaultOptions, isDragging } from '~/sortable/utils'; import TaskList from '~/task_list'; import Tracking from '~/tracking'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; + import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import animateMixin from '../mixins/animate'; +import { convertDescriptionWithNewSort } from '../utils'; Vue.use(GlToast); +const workItemTypes = { + TASK: 'task', +}; + export default { directives: { SafeHtml, @@ -74,6 +83,11 @@ export default { required: false, default: null, }, + isUpdating: { + type: Boolean, + required: false, + default: false, + }, }, data() { const workItemId = getParameterByName('work_item_id'); @@ -89,10 +103,20 @@ export default { : undefined, }; }, - computed: { - showWorkItemDetailModal() { - return Boolean(this.workItemId); + apollo: { + workItem: { + query: workItemQuery, + variables() { + return { + id: this.workItemId, + }; + }, + skip() { + return !this.workItemId || !this.workItemsEnabled; + }, }, + }, + computed: { workItemsEnabled() { return this.glFeatures.workItems; }, @@ -126,6 +150,16 @@ export default { if (this.workItemsEnabled) { this.renderTaskActions(); } + + if (this.workItemId) { + const taskLink = this.$el.querySelector( + `.gfm-issue[data-issue="${getIdFromGraphQLId(this.workItemId)}"]`, + ); + this.openWorkItemDetailModal(taskLink); + } + }, + beforeDestroy() { + this.removeAllPointerEventListeners(); }, methods: { renderGFM() { @@ -142,9 +176,67 @@ export default { onSuccess: this.taskListUpdateSuccess.bind(this), onError: this.taskListUpdateError.bind(this), }); + + this.renderSortableLists(); } }, + renderSortableLists() { + this.removeAllPointerEventListeners(); + + const lists = document.querySelectorAll('.description ul, .description ol'); + lists.forEach((list) => { + Array.from(list.children).forEach((listItem) => { + listItem.prepend(this.createDragIconElement()); + this.addPointerEventListeners(listItem); + }); + Sortable.create( + list, + getSortableDefaultOptions({ + handle: '.drag-icon', + onUpdate: (event) => { + const description = convertDescriptionWithNewSort(this.descriptionText, event.to); + this.$emit('listItemReorder', description); + }, + }), + ); + }); + }, + createDragIconElement() { + const container = document.createElement('div'); + container.innerHTML = `<svg class="drag-icon s14 gl-icon gl-cursor-grab gl-visibility-hidden" role="img" aria-hidden="true"> + <use href="${gon.sprite_icons}#drag-vertical"></use> + </svg>`; + return container.firstChild; + }, + addPointerEventListeners(listItem) { + const pointeroverListener = (event) => { + if (isDragging() || this.isUpdating) { + return; + } + event.target.closest('li').querySelector('.drag-icon').style.visibility = 'visible'; // eslint-disable-line no-param-reassign + }; + const pointeroutListener = (event) => { + event.target.closest('li').querySelector('.drag-icon').style.visibility = 'hidden'; // eslint-disable-line no-param-reassign + }; + + // We use pointerover/pointerout instead of CSS so that when we hover over a + // list item with children, the drag icons of its children do not become visible. + listItem.addEventListener('pointerover', pointeroverListener); + listItem.addEventListener('pointerout', pointeroutListener); + + this.pointerEventListeners = this.pointerEventListeners || new Map(); + this.pointerEventListeners.set(listItem, [ + { type: 'pointerover', listener: pointeroverListener }, + { type: 'pointerout', listener: pointeroutListener }, + ]); + }, + removeAllPointerEventListeners() { + this.pointerEventListeners?.forEach((events, listItem) => { + events.forEach((event) => listItem.removeEventListener(event.type, event.listener)); + this.pointerEventListeners.delete(listItem); + }); + }, taskListUpdateStarted() { this.$emit('taskListUpdateStarted'); }, @@ -195,10 +287,16 @@ export default { taskListFields.forEach((item, index) => { const taskLink = item.querySelector('.gfm-issue'); if (taskLink) { - const { issue, referenceType } = taskLink.dataset; + const { issue, referenceType, issueType } = taskLink.dataset; + if (issueType !== workItemTypes.TASK) { + return; + } + const workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue); + this.addHoverListeners(taskLink, workItemId); taskLink.addEventListener('click', (e) => { e.preventDefault(); - this.workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue); + this.openWorkItemDetailModal(taskLink); + this.workItemId = workItemId; this.updateWorkItemIdUrlQuery(issue); this.track('viewed_work_item_from_modal', { category: 'workItems:show', @@ -215,10 +313,9 @@ export default { 'btn-md', 'gl-button', 'btn-default-tertiary', - 'gl-left-0', 'gl-p-0!', - 'gl-top-2', - 'gl-absolute', + 'gl-mt-n1', + 'gl-ml-3', 'js-add-task', ); button.id = `js-task-button-${index}`; @@ -229,24 +326,46 @@ export default { </svg> `; button.setAttribute('aria-label', s__('WorkItem|Convert to work item')); - button.addEventListener('click', () => this.openCreateTaskModal(button.id)); - item.prepend(button); + button.addEventListener('click', () => this.openCreateTaskModal(button)); + item.append(button); }); }, - openCreateTaskModal(id) { - const { parentElement } = this.$el.querySelector(`#${id}`); + addHoverListeners(taskLink, id) { + let workItemPrefetch; + taskLink.addEventListener('mouseover', () => { + workItemPrefetch = setTimeout(() => { + this.workItemId = id; + }, 150); + }); + taskLink.addEventListener('mouseout', () => { + if (workItemPrefetch) { + clearTimeout(workItemPrefetch); + } + }); + }, + setActiveTask(el) { + const { parentElement } = el; const lineNumbers = parentElement.getAttribute('data-sourcepos').match(/\b\d+(?=:)/g); this.activeTask = { - id, title: parentElement.innerText, lineNumberStart: lineNumbers[0], lineNumberEnd: lineNumbers[1], }; + }, + openCreateTaskModal(el) { + this.setActiveTask(el); this.$refs.modal.show(); }, closeCreateTaskModal() { this.$refs.modal.hide(); }, + openWorkItemDetailModal(el) { + if (!el) { + return; + } + this.setActiveTask(el); + this.$refs.detailsModal.show(); + }, closeWorkItemDetailModal() { this.workItemId = undefined; this.updateWorkItemIdUrlQuery(undefined); @@ -255,7 +374,8 @@ export default { this.$emit('updateDescription', description); this.closeCreateTaskModal(); }, - handleDeleteTask() { + handleDeleteTask(description) { + this.$emit('updateDescription', description); this.$toast.show(s__('WorkItem|Work item deleted')); }, updateWorkItemIdUrlQuery(workItemId) { @@ -318,9 +438,13 @@ export default { /> </gl-modal> <work-item-detail-modal + ref="detailsModal" :can-update="canUpdate" - :visible="showWorkItemDetailModal" :work-item-id="workItemId" + :issue-gid="issueGid" + :lock-version="lockVersion" + :line-number-start="activeTask.lineNumberStart" + :line-number-end="activeTask.lineNumberEnd" @workItemDeleted="handleDeleteTask" @close="closeWorkItemDetailModal" /> diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index 4a5ebf9615b..6b0b26ef2e3 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -38,6 +38,7 @@ export function initIncidentApp(issueData = {}) { projectId, slaFeatureAvailable, uploadMetricsFeatureAvailable, + state, } = issueData; const fullPath = `${projectNamespace}/${projectPath}`; @@ -60,6 +61,7 @@ export function initIncidentApp(issueData = {}) { return createElement(IssueApp, { props: { ...issueData, + issuableStatus: state, descriptionComponent: IncidentTabs, showTitleBorder: false, }, diff --git a/app/assets/javascripts/issues/show/utils.js b/app/assets/javascripts/issues/show/utils.js new file mode 100644 index 00000000000..60e66f59f92 --- /dev/null +++ b/app/assets/javascripts/issues/show/utils.js @@ -0,0 +1,99 @@ +import { COLON, HYPHEN, NEWLINE } from '~/lib/utils/text_utility'; + +/** + * Get the index from sourcepos that represents the line of + * the description when the description is split by newline. + * + * @param {String} sourcepos Source position in format `23:3-23:14` + * @returns {Number} Index of description split by newline + */ +const getDescriptionIndex = (sourcepos) => { + const [startRange] = sourcepos.split(HYPHEN); + const [startRow] = startRange.split(COLON); + return startRow - 1; +}; + +/** + * Given a `ul` or `ol` element containing a new sort order, this function performs + * a depth-first search to get the new sort order in the form of sourcepos indices. + * + * @param {HTMLElement} list A `ul` or `ol` element containing a new sort order + * @returns {Array<Number>} An array representing the new order of the list + */ +const getNewSourcePositions = (list) => { + const newSourcePositions = []; + + function pushPositionOfChildListItems(el) { + if (!el) { + return; + } + if (el.tagName === 'LI') { + newSourcePositions.push(getDescriptionIndex(el.dataset.sourcepos)); + } + Array.from(el.children).forEach(pushPositionOfChildListItems); + } + + pushPositionOfChildListItems(list); + + return newSourcePositions; +}; + +/** + * Converts a description to one with a new list sort order. + * + * Given a description like: + * + * <pre> + * 1. I am text + * 2. + * 3. - Item 1 + * 4. - Item 2 + * 5. - Item 3 + * 6. - Item 4 + * 7. - Item 5 + * </pre> + * + * And a reordered list (due to dragging Item 2 into Item 1's position) like: + * + * <pre> + * <ul data-sourcepos="3:1-8:0"> + * <li data-sourcepos="4:1-4:8"> + * Item 2 + * <ul data-sourcepos="5:1-6:10"> + * <li data-sourcepos="5:1-5:10">Item 3</li> + * <li data-sourcepos="6:1-6:10">Item 4</li> + * </ul> + * </li> + * <li data-sourcepos="3:1-3:8">Item 1</li> + * <li data-sourcepos="7:1-8:0">Item 5</li> + * <ul> + * </pre> + * + * This function returns: + * + * <pre> + * 1. I am text + * 2. + * 3. - Item 2 + * 4. - Item 3 + * 5. - Item 4 + * 6. - Item 1 + * 7. - Item 5 + * </pre> + * + * @param {String} description Description in markdown format + * @param {HTMLElement} list A `ul` or `ol` element containing a new sort order + * @returns {String} Markdown with a new list sort order + */ +export const convertDescriptionWithNewSort = (description, list) => { + const descriptionLines = description.split(NEWLINE); + const startIndexOfList = getDescriptionIndex(list.dataset.sourcepos); + + getNewSourcePositions(list) + .map((lineIndex) => descriptionLines[lineIndex]) + .forEach((line, index) => { + descriptionLines[startIndexOfList + index] = line; + }); + + return descriptionLines.join(NEWLINE); +}; diff --git a/app/assets/javascripts/jira_connect/subscriptions/api.js b/app/assets/javascripts/jira_connect/subscriptions/api.js index 14947b6c835..de67703356f 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/api.js +++ b/app/assets/javascripts/jira_connect/subscriptions/api.js @@ -29,3 +29,13 @@ export const fetchGroups = async (groupsPath, { page, perPage, search }) => { }, }); }; + +export const fetchSubscriptions = async (subscriptionsPath) => { + const jwt = await getJwt(); + + return axios.get(subscriptionsPath, { + params: { + jwt, + }, + }); +}; diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue index 1fc40e5c0d6..fa1c2e1912c 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue @@ -1,17 +1,27 @@ <script> +import { mapActions } from 'vuex'; import { GlButton } from '@gitlab/ui'; -import { helpPagePath } from '~/helpers/help_page_helper'; import { addSubscription } from '~/jira_connect/subscriptions/api'; import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils'; -import { s__ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import GroupItemName from '../group_item_name.vue'; +import { + INTEGRATIONS_DOC_LINK, + I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE, + I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE, + I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE, +} from '../../constants'; export default { components: { GlButton, GroupItemName, }, + mixins: [glFeatureFlagMixin()], inject: { + addSubscriptionsPath: { + default: '', + }, subscriptionsPath: { default: '', }, @@ -32,31 +42,41 @@ export default { isLoading: false, }; }, + computed: { + oauthEnabled() { + return this.glFeatures.jiraConnectOauth; + }, + }, methods: { - onClick() { + ...mapActions(['addSubscription']), + async onClick() { + if (this.oauthEnabled) { + this.isLoading = true; + await this.addSubscription({ + namespacePath: this.group.full_path, + subscriptionsPath: this.subscriptionsPath, + }); + this.isLoading = false; + } else { + this.deprecatedAddSubscription(); + } + }, + deprecatedAddSubscription() { this.isLoading = true; - addSubscription(this.subscriptionsPath, this.group.full_path) + addSubscription(this.addSubscriptionsPath, this.group.full_path) .then(() => { persistAlert({ - title: s__('Integrations|Namespace successfully linked'), - message: s__( - 'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', - ), - linkUrl: helpPagePath('integration/jira_development_panel.html', { - anchor: 'use-the-integration', - }), + title: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE, + message: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE, + linkUrl: INTEGRATIONS_DOC_LINK, variant: 'success', }); reloadPage(); }) .catch((error) => { - this.$emit( - 'error', - error?.response?.data?.error || - s__('Integrations|Failed to link namespace. Please try again.'), - ); + this.$emit('error', error?.response?.data?.error || I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE); this.isLoading = false; }); }, diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index 51db3e784aa..22422872183 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -1,14 +1,14 @@ <script> import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; -import { mapState, mapMutations } from 'vuex'; +import { mapState, mapMutations, mapActions } from 'vuex'; import { retrieveAlert } from '~/jira_connect/subscriptions/utils'; import AccessorUtilities from '~/lib/utils/accessor'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '../constants'; import { SET_ALERT } from '../store/mutation_types'; -import SignInPage from '../pages/sign_in.vue'; -import SubscriptionsPage from '../pages/subscriptions.vue'; +import SignInPage from '../pages/sign_in/sign_in_page.vue'; +import SubscriptionsPage from '../pages/subscriptions_page.vue'; import UserLink from './user_link.vue'; import CompatibilityAlert from './compatibility_alert.vue'; import BrowserSupportAlert from './browser_support_alert.vue'; @@ -30,17 +30,13 @@ export default { usersPath: { default: '', }, - subscriptions: { - default: [], + subscriptionsPath: { + default: '', }, }, - data() { - return { - user: null, - }; - }, computed: { - ...mapState(['alert']), + ...mapState(['currentUser']), + ...mapState(['alert', 'subscriptions']), shouldShowAlert() { return Boolean(this.alert?.message); }, @@ -48,7 +44,11 @@ export default { return !isEmpty(this.subscriptions); }, userSignedIn() { - return Boolean(!this.usersPath || this.user); + if (this.isOauthEnabled) { + return Boolean(this.currentUser); + } + + return Boolean(!this.usersPath); }, isOauthEnabled() { return this.glFeatures.jiraConnectOauth; @@ -64,16 +64,29 @@ export default { created() { this.setInitialAlert(); }, + mounted() { + this.fetchSubscriptionsOauth(); + }, methods: { ...mapMutations({ setAlert: SET_ALERT, }), + ...mapActions(['fetchSubscriptions']), + /** + * Fetch subscriptions from the REST API, + * if the jiraConnectOauth flag is enabled. + */ + fetchSubscriptionsOauth() { + if (!this.isOauthEnabled) return; + + this.fetchSubscriptions(this.subscriptionsPath); + }, setInitialAlert() { const { linkUrl, title, message, variant } = retrieveAlert() || {}; this.setAlert({ linkUrl, title, message, variant }); }, - onSignInOauth(user) { - this.user = user; + onSignInOauth() { + this.fetchSubscriptionsOauth(); }, onSignInError() { this.setAlert({ @@ -109,9 +122,12 @@ export default { </template> </gl-alert> - <user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" :user="user" /> + <user-link + :user-signed-in="userSignedIn" + :has-subscriptions="hasSubscriptions" + :user="currentUser" + /> - <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> <div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7"> <sign-in-page v-if="!userSignedIn" diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue index dfed57df7d6..b9e8bab019f 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue @@ -1,4 +1,5 @@ <script> +import { mapActions, mapMutations } from 'vuex'; import { GlButton } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { @@ -8,8 +9,8 @@ import { } from '~/jira_connect/subscriptions/constants'; import { setUrlParams } from '~/lib/utils/url_utility'; import AccessorUtilities from '~/lib/utils/accessor'; - import { createCodeVerifier, createCodeChallenge } from '../pkce'; +import { SET_ACCESS_TOKEN } from '../store/mutation_types'; export default { components: { @@ -31,6 +32,10 @@ export default { window.removeEventListener('message', this.handleWindowMessage); }, methods: { + ...mapActions(['loadCurrentUser']), + ...mapMutations({ + setAccessToken: SET_ACCESS_TOKEN, + }), async startOAuthFlow() { this.loading = true; @@ -40,6 +45,7 @@ export default { // Build the initial OAuth authorization URL const { oauth_authorize_url: oauthAuthorizeURL } = this.oauthMetadata; + const oauthAuthorizeURLWithChallenge = setUrlParams( { code_challenge: codeChallenge, @@ -57,7 +63,6 @@ export default { async handleWindowMessage(event) { if (window.origin !== event.origin) { this.loading = false; - this.handleError(); return; } @@ -73,7 +78,10 @@ export default { const code = event.data?.code; try { const accessToken = await this.getOAuthToken(code); - await this.loadUser(accessToken); + await this.loadCurrentUser(accessToken); + + this.setAccessToken(accessToken); + this.$emit('sign-in'); } catch (e) { this.handleError(); } finally { @@ -96,13 +104,6 @@ export default { return data.access_token; }, - async loadUser(accessToken) { - const { data } = await axios.get('/api/v4/user', { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - this.$emit('sign-in', data); - }, }, i18n: { defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue index 0251728c896..4c039be9ba5 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlTableLite } from '@gitlab/ui'; import { isEmpty } from 'lodash'; -import { mapMutations } from 'vuex'; +import { mapMutations, mapState } from 'vuex'; import { removeSubscription } from '~/jira_connect/subscriptions/api'; import { reloadPage } from '~/jira_connect/subscriptions/utils'; import { __, s__ } from '~/locale'; @@ -16,11 +16,6 @@ export default { GroupItemName, TimeagoTooltip, }, - inject: { - subscriptions: { - default: [], - }, - }, data() { return { loadingItem: null, @@ -45,6 +40,9 @@ export default { i18n: { unlinkError: s__('Integrations|Failed to unlink namespace. Please try again.'), }, + computed: { + ...mapState(['subscriptions']), + }, methods: { ...mapMutations({ setAlert: SET_ALERT, diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index d30ebdbb487..8faafb1b0d0 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js @@ -1,4 +1,5 @@ import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const DEFAULT_GROUPS_PER_PAGE = 10; export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert'; @@ -8,6 +9,22 @@ export const ADD_NAMESPACE_MODAL_ID = 'add-namespace-modal'; export const I18N_DEFAULT_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to GitLab'); export const I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('Integrations|Failed to sign in to GitLab.'); +export const I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE = s__( + 'Integrations|Failed to load subscriptions.', +); +export const I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE = s__( + 'Integrations|Namespace successfully linked', +); +export const I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE = s__( + 'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', +); +export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira_development_panel', { + anchor: 'use-the-integration', +}); + +export const I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE = s__( + 'Integrations|Failed to link namespace. Please try again.', +); const OAUTH_WINDOW_SIZE = 800; export const OAUTH_WINDOW_OPTIONS = [ diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js index 3b584b5fe98..8e9f73538b9 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/index.js +++ b/app/assets/javascripts/jira_connect/subscriptions/index.js @@ -9,8 +9,6 @@ import JiraConnectApp from './components/app.vue'; import createStore from './store'; import { sizeToParent } from './utils'; -const store = createStore(); - export function initJiraConnect() { const el = document.querySelector('.js-jira-connect-app'); if (!el) { @@ -24,6 +22,7 @@ export function initJiraConnect() { const { groupsPath, subscriptions, + addSubscriptionsPath, subscriptionsPath, usersPath, gitlabUserPath, @@ -31,12 +30,14 @@ export function initJiraConnect() { } = el.dataset; sizeToParent(); + const store = createStore({ subscriptions: JSON.parse(subscriptions) }); + return new Vue({ el, store, provide: { groupsPath, - subscriptions: JSON.parse(subscriptions), + addSubscriptionsPath, subscriptionsPath, usersPath, gitlabUserPath, diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue deleted file mode 100644 index a24ee33b723..00000000000 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> -import { s__ } from '~/locale'; - -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import SubscriptionsList from '../components/subscriptions_list.vue'; - -export default { - name: 'SignInPage', - components: { - SubscriptionsList, - SignInLegacyButton: () => import('../components/sign_in_legacy_button.vue'), - SignInOauthButton: () => import('../components/sign_in_oauth_button.vue'), - }, - mixins: [glFeatureFlagMixin()], - inject: ['usersPath'], - props: { - hasSubscriptions: { - type: Boolean, - required: true, - }, - }, - computed: { - useSignInOauthButton() { - return this.glFeatures.jiraConnectOauth; - }, - }, - i18n: { - signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'), - signInText: s__('JiraService|Sign in to GitLab.com to get started.'), - }, - methods: { - onSignInError() { - this.$emit('error'); - }, - }, -}; -</script> - -<template> - <div v-if="hasSubscriptions"> - <div class="gl-display-flex gl-justify-content-end"> - <sign-in-oauth-button - v-if="useSignInOauthButton" - @sign-in="$emit('sign-in-oauth', $event)" - @error="onSignInError" - > - {{ $options.i18n.signInButtonTextWithSubscriptions }} - </sign-in-oauth-button> - <sign-in-legacy-button v-else :users-path="usersPath"> - {{ $options.i18n.signInButtonTextWithSubscriptions }} - </sign-in-legacy-button> - </div> - - <subscriptions-list /> - </div> - <div v-else class="gl-text-center"> - <p class="gl-mb-7">{{ $options.i18n.signInText }}</p> - <sign-in-oauth-button - v-if="useSignInOauthButton" - @sign-in="$emit('sign-in-oauth', $event)" - @error="onSignInError" - /> - <sign-in-legacy-button v-else class="gl-mb-7" :users-path="usersPath" /> - </div> -</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue new file mode 100644 index 00000000000..91b66c87694 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue @@ -0,0 +1,68 @@ +<script> +import { s__ } from '~/locale'; + +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import SubscriptionsList from '../../components/subscriptions_list.vue'; + +export default { + name: 'SignInGitlabCom', + components: { + SubscriptionsList, + SignInLegacyButton: () => import('../../components/sign_in_legacy_button.vue'), + SignInOauthButton: () => import('../../components/sign_in_oauth_button.vue'), + }, + mixins: [glFeatureFlagMixin()], + inject: ['usersPath'], + props: { + hasSubscriptions: { + type: Boolean, + required: true, + }, + }, + computed: { + useSignInOauthButton() { + return this.glFeatures.jiraConnectOauth; + }, + }, + i18n: { + signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'), + signInText: s__('JiraService|Sign in to GitLab.com to get started.'), + }, + methods: { + onSignInError() { + this.$emit('error'); + }, + }, +}; +</script> + +<template> + <div> + <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> + <div v-if="hasSubscriptions"> + <div class="gl-display-flex gl-justify-content-end gl-mb-3"> + <sign-in-oauth-button + v-if="useSignInOauthButton" + @sign-in="$emit('sign-in-oauth', $event)" + @error="onSignInError" + > + {{ $options.i18n.signInButtonTextWithSubscriptions }} + </sign-in-oauth-button> + <sign-in-legacy-button v-else :users-path="usersPath"> + {{ $options.i18n.signInButtonTextWithSubscriptions }} + </sign-in-legacy-button> + </div> + + <subscriptions-list /> + </div> + <div v-else class="gl-text-center"> + <p class="gl-mb-7">{{ $options.i18n.signInText }}</p> + <sign-in-oauth-button + v-if="useSignInOauthButton" + @sign-in="$emit('sign-in-oauth', $event)" + @error="onSignInError" + /> + <sign-in-legacy-button v-else class="gl-mb-7" :users-path="usersPath" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue new file mode 100644 index 00000000000..4f5aa4c255c --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue @@ -0,0 +1,72 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import SignInOauthButton from '../../../components/sign_in_oauth_button.vue'; +import VersionSelectForm from './version_select_form.vue'; + +export default { + name: 'SignInGitlabMultiversion', + components: { + GlButton, + SignInOauthButton, + VersionSelectForm, + }, + data() { + return { + gitlabBasePath: null, + }; + }, + computed: { + hasSelectedVersion() { + return this.gitlabBasePath !== null; + }, + subtitle() { + return this.hasSelectedVersion + ? this.$options.i18n.signInSubtitle + : this.$options.i18n.versionSelectSubtitle; + }, + }, + methods: { + resetGitlabBasePath() { + this.gitlabBasePath = null; + }, + onVersionSelect(gitlabBasePath) { + this.gitlabBasePath = gitlabBasePath; + }, + onSignInError() { + this.$emit('error'); + }, + }, + i18n: { + title: s__('JiraService|Welcome to GitLab for Jira'), + signInSubtitle: s__('JiraService|Sign in to GitLab to link namespaces.'), + versionSelectSubtitle: s__('JiraService|What version of GitLab are you using?'), + changeVersionButtonText: s__('JiraService|Change GitLab version'), + }, +}; +</script> + +<template> + <div> + <div class="gl-text-center"> + <h2>{{ $options.i18n.title }}</h2> + <p data-testid="subtitle">{{ subtitle }}</p> + </div> + + <version-select-form v-if="!hasSelectedVersion" class="gl-mt-7" @submit="onVersionSelect" /> + + <div v-else class="gl-text-center"> + <sign-in-oauth-button + class="gl-mb-5" + @sign-in="$emit('sign-in-oauth', $event)" + @error="onSignInError" + /> + + <div> + <gl-button category="tertiary" variant="confirm" @click="resetGitlabBasePath"> + {{ $options.i18n.changeVersionButtonText }} + </gl-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue new file mode 100644 index 00000000000..0fa745ed7e3 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue @@ -0,0 +1,88 @@ +<script> +import { + GlForm, + GlFormGroup, + GlFormRadioGroup, + GlFormInput, + GlFormRadio, + GlButton, +} from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +const RADIO_OPTIONS = { + saas: 'saas', + selfManaged: 'selfManaged', +}; + +const DEFAULT_RADIO_OPTION = RADIO_OPTIONS.saas; +const GITLAB_COM_BASE_PATH = 'https://gitlab.com'; + +export default { + name: 'VersionSelectForm', + components: { + GlForm, + GlFormGroup, + GlFormRadioGroup, + GlFormInput, + GlFormRadio, + GlButton, + }, + data() { + return { + selected: DEFAULT_RADIO_OPTION, + selfManagedBasePathInput: '', + }; + }, + computed: { + isSelfManagedSelected() { + return this.selected === RADIO_OPTIONS.selfManaged; + }, + }, + methods: { + onSubmit() { + const gitlabBasePath = + this.selected === RADIO_OPTIONS.saas ? GITLAB_COM_BASE_PATH : this.selfManagedBasePathInput; + this.$emit('submit', gitlabBasePath); + }, + }, + radioOptions: RADIO_OPTIONS, + i18n: { + title: s__('JiraService|Welcome to GitLab for Jira'), + saasRadioLabel: __('GitLab.com (SaaS)'), + saasRadioHelp: __('Most common'), + selfManagedRadioLabel: __('GitLab (self-managed)'), + instanceURLInputLabel: s__('JiraService|GitLab instance URL'), + instanceURLInputDescription: s__('JiraService|For example: https://gitlab.example.com'), + }, +}; +</script> + +<template> + <gl-form class="gl-max-w-62 gl-mx-auto" @submit.prevent="onSubmit"> + <gl-form-radio-group v-model="selected" class="gl-mb-3" name="gitlab_version"> + <gl-form-radio :value="$options.radioOptions.saas"> + {{ $options.i18n.saasRadioLabel }} + <template #help> + {{ $options.i18n.saasRadioHelp }} + </template> + </gl-form-radio> + <gl-form-radio :value="$options.radioOptions.selfManaged"> + {{ $options.i18n.selfManagedRadioLabel }} + </gl-form-radio> + </gl-form-radio-group> + + <gl-form-group + v-if="isSelfManagedSelected" + class="gl-ml-6" + :label="$options.i18n.instanceURLInputLabel" + :description="$options.i18n.instanceURLInputDescription" + label-for="self-managed-instance-input" + > + <gl-form-input id="self-managed-instance-input" v-model="selfManagedBasePathInput" required /> + </gl-form-group> + + <div class="gl-display-flex gl-justify-content-end"> + <gl-button variant="confirm" type="submit">{{ __('Save') }}</gl-button> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue new file mode 100644 index 00000000000..f4c59b2184e --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue @@ -0,0 +1,35 @@ +<script> +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import SignInGitlabCom from './sign_in_gitlab_com.vue'; +import SignInGitlabMultiversion from './sign_in_gitlab_multiversion/index.vue'; + +export default { + name: 'SignInPage', + components: { SignInGitlabCom, SignInGitlabMultiversion }, + mixins: [glFeatureFlagMixin()], + props: { + hasSubscriptions: { + type: Boolean, + required: true, + }, + }, + computed: { + isOauthSelfManagedEnabled() { + return this.glFeatures.jiraConnectOauth && this.glFeatures.jiraConnectOauthSelfManaged; + }, + }, +}; +</script> +<template> + <sign-in-gitlab-multiversion + v-if="isOauthSelfManagedEnabled" + @sign-in-oauth="$emit('sign-in-oauth', $event)" + @error="$emit('error', $event)" + /> + <sign-in-gitlab-com + v-else + :has-subscriptions="hasSubscriptions" + @sign-in-oauth="$emit('sign-in-oauth')" + @error="$emit('error', $event)" + /> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue deleted file mode 100644 index 426f2999370..00000000000 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue +++ /dev/null @@ -1,43 +0,0 @@ -<script> -import { GlEmptyState } from '@gitlab/ui'; -import SubscriptionsList from '../components/subscriptions_list.vue'; -import AddNamespaceButton from '../components/add_namespace_button.vue'; - -export default { - name: 'SubscriptionsPage', - components: { - GlEmptyState, - SubscriptionsList, - AddNamespaceButton, - }, - props: { - hasSubscriptions: { - type: Boolean, - required: true, - }, - }, -}; -</script> - -<template> - <div v-if="hasSubscriptions"> - <div class="gl-display-flex gl-justify-content-end"> - <add-namespace-button /> - </div> - - <subscriptions-list /> - </div> - <gl-empty-state - v-else - :title="s__('Integrations|No linked namespaces')" - :description=" - s__( - 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.', - ) - " - > - <template #actions> - <add-namespace-button /> - </template> - </gl-empty-state> -</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue new file mode 100644 index 00000000000..b1c1ae73e14 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue @@ -0,0 +1,54 @@ +<script> +import { mapState } from 'vuex'; +import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; + +import SubscriptionsList from '../components/subscriptions_list.vue'; +import AddNamespaceButton from '../components/add_namespace_button.vue'; + +export default { + name: 'SubscriptionsPage', + components: { + GlEmptyState, + GlLoadingIcon, + SubscriptionsList, + AddNamespaceButton, + }, + props: { + hasSubscriptions: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapState(['subscriptionsLoading', 'subscriptionsError']), + }, +}; +</script> + +<template> + <div> + <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> + + <gl-loading-icon v-if="subscriptionsLoading" size="md" /> + <div v-else-if="hasSubscriptions && !subscriptionsError"> + <div class="gl-display-flex gl-justify-content-end gl-mb-3"> + <add-namespace-button /> + </div> + + <subscriptions-list /> + </div> + <gl-empty-state + v-else + :title="s__('Integrations|No linked namespaces')" + :description=" + s__( + 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.', + ) + " + > + <template #actions> + <add-namespace-button /> + </template> + </gl-empty-state> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/actions.js b/app/assets/javascripts/jira_connect/subscriptions/store/actions.js new file mode 100644 index 00000000000..4a83ee8671d --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/store/actions.js @@ -0,0 +1,73 @@ +import { fetchSubscriptions as fetchSubscriptionsREST } from '~/jira_connect/subscriptions/api'; +import { getCurrentUser } from '~/rest_api'; +import { addJiraConnectSubscription } from '~/api/integrations_api'; +import { + I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE, + I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE, + INTEGRATIONS_DOC_LINK, + I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE, +} from '../constants'; +import { getJwt } from '../utils'; +import { + SET_SUBSCRIPTIONS, + SET_SUBSCRIPTIONS_LOADING, + SET_SUBSCRIPTIONS_ERROR, + ADD_SUBSCRIPTION_LOADING, + ADD_SUBSCRIPTION_ERROR, + SET_ALERT, + SET_CURRENT_USER, + SET_CURRENT_USER_ERROR, +} from './mutation_types'; + +export const fetchSubscriptions = async ({ commit }, subscriptionsPath) => { + commit(SET_SUBSCRIPTIONS_LOADING, true); + + try { + const data = await fetchSubscriptionsREST(subscriptionsPath); + commit(SET_SUBSCRIPTIONS, data.data.subscriptions); + } catch { + commit(SET_SUBSCRIPTIONS_ERROR, true); + commit(SET_ALERT, { message: I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE, variant: 'danger' }); + } finally { + commit(SET_SUBSCRIPTIONS_LOADING, false); + } +}; + +export const loadCurrentUser = async ({ commit }, accessToken) => { + try { + const { data: user } = await getCurrentUser({ + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + commit(SET_CURRENT_USER, user); + } catch (e) { + commit(SET_CURRENT_USER_ERROR, e); + } +}; + +export const addSubscription = async ( + { commit, state, dispatch }, + { namespacePath, subscriptionsPath }, +) => { + try { + commit(ADD_SUBSCRIPTION_LOADING, true); + + await addJiraConnectSubscription(namespacePath, { + jwt: await getJwt(), + accessToken: state.accessToken, + }); + + commit(SET_ALERT, { + title: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE, + message: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE, + linkUrl: INTEGRATIONS_DOC_LINK, + variant: 'success', + }); + + dispatch('fetchSubscriptions', subscriptionsPath); + } catch (e) { + commit(ADD_SUBSCRIPTION_ERROR, e); + } finally { + commit(ADD_SUBSCRIPTION_LOADING, false); + } +}; diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/index.js b/app/assets/javascripts/jira_connect/subscriptions/store/index.js index de830e3891a..abad1920bcc 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/store/index.js +++ b/app/assets/javascripts/jira_connect/subscriptions/store/index.js @@ -1,12 +1,15 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import * as actions from './actions'; import mutations from './mutations'; -import state from './state'; +import createState from './state'; Vue.use(Vuex); -export default () => - new Vuex.Store({ +export default function createStore(initialState) { + return new Vuex.Store({ mutations, - state, + actions, + state: createState(initialState), }); +} diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js b/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js index 15f36b824d9..d4893fbcaf6 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js +++ b/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js @@ -1 +1,13 @@ export const SET_ALERT = 'SET_ALERT'; + +export const SET_SUBSCRIPTIONS = 'SET_SUBSCRIPTIONS'; +export const SET_SUBSCRIPTIONS_LOADING = 'SET_SUBSCRIPTIONS_LOADING'; +export const SET_SUBSCRIPTIONS_ERROR = 'SET_SUBSCRIPTIONS_ERROR'; + +export const ADD_SUBSCRIPTION_LOADING = 'ADD_SUBSCRIPTION_LOADING'; +export const ADD_SUBSCRIPTION_ERROR = 'ADD_SUBSCRIPTION_ERROR'; + +export const SET_CURRENT_USER = 'SET_CURRENT_USER'; +export const SET_CURRENT_USER_ERROR = 'SET_CURRENT_USER_ERROR'; + +export const SET_ACCESS_TOKEN = 'SET_ACCESS_TOKEN'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js b/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js index 2a25e0fe25f..60076c918fd 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js +++ b/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js @@ -1,7 +1,45 @@ -import { SET_ALERT } from './mutation_types'; +import { + SET_ALERT, + SET_SUBSCRIPTIONS, + SET_SUBSCRIPTIONS_LOADING, + SET_SUBSCRIPTIONS_ERROR, + ADD_SUBSCRIPTION_LOADING, + ADD_SUBSCRIPTION_ERROR, + SET_CURRENT_USER, + SET_CURRENT_USER_ERROR, + SET_ACCESS_TOKEN, +} from './mutation_types'; export default { [SET_ALERT](state, { title, message, variant, linkUrl } = {}) { state.alert = { title, message, variant, linkUrl }; }, + + [SET_SUBSCRIPTIONS](state, subscriptions = []) { + state.subscriptions = subscriptions; + }, + [SET_SUBSCRIPTIONS_LOADING](state, subscriptionsLoading) { + state.subscriptionsLoading = subscriptionsLoading; + }, + [SET_SUBSCRIPTIONS_ERROR](state, subscriptionsError) { + state.subscriptionsError = subscriptionsError; + }, + + [ADD_SUBSCRIPTION_LOADING](state, loading) { + state.addSubscriptionLoading = loading; + }, + [ADD_SUBSCRIPTION_ERROR](state, error) { + state.addSubscriptionError = error; + }, + + [SET_CURRENT_USER](state, currentUser) { + state.currentUser = currentUser; + }, + [SET_CURRENT_USER_ERROR](state, currentUserError) { + state.currentUserError = currentUserError; + }, + + [SET_ACCESS_TOKEN](state, accessToken) { + state.accessToken = accessToken; + }, }; diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/state.js b/app/assets/javascripts/jira_connect/subscriptions/store/state.js index c807df03f00..03a83f18b4c 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/store/state.js +++ b/app/assets/javascripts/jira_connect/subscriptions/store/state.js @@ -1,3 +1,17 @@ -export default () => ({ - alert: undefined, -}); +export default function createState({ subscriptions = [], subscriptionsLoading = false } = {}) { + return { + alert: undefined, + + subscriptions, + subscriptionsLoading, + subscriptionsError: false, + + addSubscriptionLoading: false, + addSubscriptionError: false, + + currentUser: null, + currentUserError: null, + + accessToken: null, + }; +} diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue index af4a26a7352..8a36a4d2466 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -304,7 +304,10 @@ export default { :text="data.value || $options.currentUsername" class="w-100" :aria-label=" - sprintf($options.dropdownLabel, { jiraDisplayName: data.item.jiraDisplayName }) + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf($options.dropdownLabel, { + jiraDisplayName: data.item.jiraDisplayName, + }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */ " @hide="resetDropdown" > diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 85fe5ed7e26..396b015ad83 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -94,7 +94,7 @@ export default { 'emptyStateIllustration', 'isScrollingDown', 'emptyStateAction', - 'hasRunnersForProject', + 'hasOfflineRunnersForProject', ]), shouldRenderContent() { @@ -220,7 +220,7 @@ export default { <!-- Body Section --> <stuck-block v-if="job.stuck" - :has-no-runners-for-project="hasRunnersForProject" + :has-offline-runners-for-project="hasOfflineRunnersForProject" :tags="job.tags" :runners-path="runnerSettingsUrl" /> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 1b4c9ebdf7d..cc099dba72f 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -125,6 +125,7 @@ export default { :title="$options.i18n.cancelJobButtonLabel" :aria-label="$options.i18n.cancelJobButtonLabel" :href="job.cancel_path" + variant="danger" icon="cancel" data-method="post" data-testid="cancel-button" diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue index 5428f657252..2ba531c9e95 100644 --- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue @@ -56,7 +56,7 @@ export default { }); }, runnerId() { - const { id, short_sha: token, description } = this.job?.runner; + const { id, short_sha: token, description } = this.job.runner; return `#${id} (${token}) ${description}`; }, @@ -71,11 +71,20 @@ export default { return ''; } - return sprintf(__(` (from %{timeoutSource})`), { + return sprintf(__(' (from %{timeoutSource})'), { timeoutSource: this.job.metadata.timeout_source, }); }, }, + i18n: { + COVERAGE: __('Coverage'), + FINISHED: __('Finished'), + ERASED: __('Erased'), + QUEUED: __('Queued'), + RUNNER: __('Runner'), + TAGS: __('Tags:'), + TIMEOUT: __('Timeout'), + }, }; </script> @@ -86,22 +95,22 @@ export default { v-if="job.finished_at" :value="finishedAt" data-testid="job-finished" - title="Finished" + :title="$options.i18n.FINISHED" /> - <detail-row v-if="job.erased_at" :value="erasedAt" title="Erased" /> - <detail-row v-if="job.queued" :value="queued" title="Queued" /> + <detail-row v-if="job.erased_at" :value="erasedAt" :title="$options.i18n.ERASED" /> + <detail-row v-if="job.queued" :value="queued" :title="$options.i18n.QUEUED" /> <detail-row v-if="hasTimeout" :help-url="runnerHelpUrl" :value="timeout" data-testid="job-timeout" - title="Timeout" + :title="$options.i18n.TIMEOUT" /> - <detail-row v-if="job.runner" :value="runnerId" title="Runner" /> - <detail-row v-if="job.coverage" :value="coverage" title="Coverage" /> + <detail-row v-if="job.runner" :value="runnerId" :title="$options.i18n.RUNNER" /> + <detail-row v-if="job.coverage" :value="coverage" :title="$options.i18n.COVERAGE" /> <p v-if="hasTags" class="build-detail-row" data-testid="job-tags"> - <span class="font-weight-bold">{{ __('Tags:') }}</span> + <span class="font-weight-bold">{{ $options.i18n.TAGS }}</span> <gl-badge v-for="(tag, i) in job.tags" :key="i" variant="info">{{ tag }}</gl-badge> </p> </div> diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue index abd0c13702a..f9cde61e917 100644 --- a/app/assets/javascripts/jobs/components/stuck_block.vue +++ b/app/assets/javascripts/jobs/components/stuck_block.vue @@ -11,7 +11,7 @@ export default { GlLink, }, props: { - hasNoRunnersForProject: { + hasOfflineRunnersForProject: { type: Boolean, required: true, }, @@ -37,7 +37,7 @@ export default { dataTestId: 'job-stuck-with-tags', showTags: true, }; - } else if (this.hasNoRunnersForProject) { + } else if (this.hasOfflineRunnersForProject) { return { text: s__(`Job|This job is stuck because the project doesn't have any runners online assigned to it.`), diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue index f16e0287d5d..02aeb46a22b 100644 --- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlButtonGroup, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; +import { redirectTo } from '~/lib/utils/url_utility'; import { ACTIONS_DOWNLOAD_ARTIFACTS, ACTIONS_START_NOW, @@ -108,11 +109,11 @@ export default { }, }, methods: { - async postJobAction(name, mutation) { + async postJobAction(name, mutation, redirect = false) { try { const { data: { - [name]: { errors }, + [name]: { errors, job }, }, } = await this.$apollo.mutate({ mutation, @@ -121,6 +122,10 @@ export default { if (errors.length > 0) { reportMessageToSentry(this.$options.name, errors.join(', '), {}); this.showToastMessage(); + } else if (redirect) { + // Retry and Play actions redirect to job detail view + // we don't need to refetch with jobActionPerformed event + redirectTo(job.detailedStatus.detailsPath); } else { eventHub.$emit('jobActionPerformed'); } @@ -147,12 +152,12 @@ export default { retryJob() { this.retryBtnDisabled = true; - this.postJobAction(this.$options.jobRetry, retryJobMutation); + this.postJobAction(this.$options.jobRetry, retryJobMutation, true); }, playJob() { this.playManualBtnDisabled = true; - this.postJobAction(this.$options.jobPlay, playJobMutation); + this.postJobAction(this.$options.jobPlay, playJobMutation, true); }, unscheduleJob() { this.unscheduleBtnDisabled = true; diff --git a/app/assets/javascripts/jobs/components/table/graphql/fragments/job.fragment.graphql b/app/assets/javascripts/jobs/components/table/graphql/fragments/job.fragment.graphql index 06b065a86ce..3038216fdfc 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/fragments/job.fragment.graphql +++ b/app/assets/javascripts/jobs/components/table/graphql/fragments/job.fragment.graphql @@ -1,3 +1,7 @@ fragment Job on CiJob { id + detailedStatus { + id + detailsPath + } } diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue index 0a25dc5bea5..27e3b8028b7 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue @@ -54,7 +54,9 @@ export default { <gl-tab v-for="tab in tabs" :key="tab.text" - :title-link-attributes="{ 'data-testid': tab.testId }" + :title-link-attributes="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + 'data-testid': tab.testId, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @click="$emit('fetchJobsByStatus', tab.scope)" > <template #title> diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js index 9d255822250..a0f9db7409d 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/jobs/store/getters.js @@ -1,7 +1,7 @@ -import { isEmpty, isString } from 'lodash'; +import { isEmpty } from 'lodash'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; -export const headerTime = (state) => (state.job.started ? state.job.started : state.job.created_at); +export const headerTime = (state) => state.job.started_at || state.job.created_at; export const hasForwardDeploymentFailure = (state) => state?.job?.failure_reason === 'forward_deployment_failure'; @@ -13,10 +13,10 @@ export const shouldRenderCalloutMessage = (state) => !isEmpty(state.job.status) && !isEmpty(state.job.callout_message); /** - * When job has not started the key will be null - * When job started the key will be a string with a date. + * When the job has not started the value of job.started_at will be null + * When job has started the value of job.started_at will be a string with a date. */ -export const shouldRenderTriggeredLabel = (state) => isString(state.job.started); +export const shouldRenderTriggeredLabel = (state) => Boolean(state.job.started_at); export const hasEnvironment = (state) => !isEmpty(state.job.deployment_status); @@ -46,5 +46,5 @@ export const shouldRenderSharedRunnerLimitWarning = (state) => export const isScrollingDown = (state) => isScrolledToBottom() && !state.isJobLogComplete; -export const hasRunnersForProject = (state) => +export const hasOfflineRunnersForProject = (state) => state?.job?.runners?.available && !state?.job?.runners?.online; diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js index 47568f0ecff..4959550e273 100644 --- a/app/assets/javascripts/lib/dompurify.js +++ b/app/assets/javascripts/lib/dompurify.js @@ -7,6 +7,7 @@ const defaultConfig = { // Prevent possible XSS attacks with data-* attributes used by @rails/ujs // See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1421 FORBID_ATTR: ['data-remote', 'data-url', 'data-type', 'data-method'], + FORBID_TAGS: ['style', 'mstyle'], }; // Only icons urls from `gon` are allowed diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js index 07388f1fdfa..4e704eb69b2 100644 --- a/app/assets/javascripts/lib/gfm/index.js +++ b/app/assets/javascripts/lib/gfm/index.js @@ -32,7 +32,7 @@ const compilerFactory = (renderer) => * the MDast tree */ export const render = async ({ markdown, renderer }) => { - const { value } = await createParser().use(compilerFactory(renderer)).process(markdown); + const { result } = await createParser().use(compilerFactory(renderer)).process(markdown); - return value; + return result; }; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 96d019f62f2..1ed0cc3130b 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -4,15 +4,15 @@ import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; -import Cookies from 'js-cookie'; import { isFunction, defer } from 'lodash'; +import Cookies from '~/lib/utils/cookies'; import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import { convertToCamelCase, convertToSnakeCase } from './text_utility'; import { isObject } from './type_utility'; import { getLocationHash } from './url_utility'; export const getPagePath = (index = 0) => { - const { page = '' } = document?.body?.dataset; + const { page = '' } = document.body.dataset; return page.split(':')[index]; }; @@ -105,7 +105,7 @@ export const handleLocationHash = () => { } if (isInIssuePage()) { - adjustment -= fixedIssuableTitle?.offsetHeight; + adjustment -= fixedIssuableTitle.offsetHeight; } if (isInMRPage()) { @@ -157,7 +157,7 @@ export const contentTop = () => { () => getOuterHeight('#js-peek'), () => getOuterHeight('.navbar-gitlab'), ({ desktop }) => { - const container = document.querySelector('.line-resolve-all-container'); + const container = document.querySelector('.discussions-counter'); let size = 0; if (!desktop && container) { @@ -282,23 +282,51 @@ export const getSelectedFragment = (restrictToNode) => { return documentFragment; }; +function execInsertText(text) { + if (text === '') return document.execCommand('delete'); + + return document.execCommand('insertText', false, text); +} + +/** + * This method inserts text into a textarea/input field. + * Uses `execCommand` if supported + * + * @param {HTMLElement} target - textarea/input to have text inserted into + * @param {String | function} text - text to be inserted + */ export const insertText = (target, text) => { - // Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas const { selectionStart, selectionEnd, value } = target; - const textBefore = value.substring(0, selectionStart); const textAfter = value.substring(selectionEnd, value.length); - const insertedText = text instanceof Function ? text(textBefore, textAfter) : text; - const newText = textBefore + insertedText + textAfter; - // eslint-disable-next-line no-param-reassign - target.value = newText; - // eslint-disable-next-line no-param-reassign - target.selectionStart = selectionStart + insertedText.length; - - // eslint-disable-next-line no-param-reassign - target.selectionEnd = selectionStart + insertedText.length; + // The `execCommand` is officially deprecated. However, for `insertText`, + // there is currently no alternative. We need to use it in order to trigger + // the browser's undo tracking when we insert text. + // Per https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand on 2022-04-11, + // The Clipboard API can be used instead of execCommand in many cases, + // but execCommand is still sometimes useful. In particular, the Clipboard + // API doesn't replace the insertText command + // So we attempt to use it if possible. Otherwise, fall back to just replacing + // the value as before. In this case, Undo will be broken with inserted text. + // Testing on older versions of Firefox: + // 87 and below: does not work and falls through to just replacing value. + // 87 was released in Mar of 2021 + // 89 and above: works well + // 89 was released in May of 2021 + if (!execInsertText(insertedText)) { + const newText = textBefore + insertedText + textAfter; + + // eslint-disable-next-line no-param-reassign + target.value = newText; + + // eslint-disable-next-line no-param-reassign + target.selectionStart = selectionStart + insertedText.length; + + // eslint-disable-next-line no-param-reassign + target.selectionEnd = selectionStart + insertedText.length; + } // Trigger autosave target.dispatchEvent(new Event('input')); diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue index 1d8eb73d3d7..3788d8ab20c 100644 --- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue +++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue @@ -3,7 +3,6 @@ import { GlModal, GlSafeHtmlDirective } from '@gitlab/ui'; import { __ } from '~/locale'; export default { - cancelAction: { text: __('Cancel') }, directives: { SafeHtml: GlSafeHtmlDirective, }, @@ -36,6 +35,16 @@ export default { required: false, default: 'confirm', }, + cancelText: { + type: String, + required: false, + default: __('Cancel'), + }, + cancelVariant: { + type: String, + required: false, + default: 'default', + }, modalHtmlMessage: { type: String, required: false, @@ -71,7 +80,14 @@ export default { }; }, cancelAction() { - return this.hideCancel ? null : this.$options.cancelAction; + return this.hideCancel + ? null + : { + text: this.cancelText, + attributes: { + variant: this.cancelVariant, + }, + }; }, shouldShowHeader() { return Boolean(this.title?.length); diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js index 1adb6f9c26f..173116062c9 100644 --- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js +++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js @@ -7,6 +7,8 @@ export function confirmAction( primaryBtnText, secondaryBtnVariant, secondaryBtnText, + cancelBtnVariant, + cancelBtnText, modalHtmlMessage, title, hideCancel, @@ -28,6 +30,8 @@ export function confirmAction( secondaryVariant: secondaryBtnVariant, primaryVariant: primaryBtnVariant, primaryText: primaryBtnText, + cancelVariant: cancelBtnVariant, + cancelText: cancelBtnText, title, modalHtmlMessage, hideCancel, diff --git a/app/assets/javascripts/lib/utils/cookies.js b/app/assets/javascripts/lib/utils/cookies.js new file mode 100644 index 00000000000..be0491376c9 --- /dev/null +++ b/app/assets/javascripts/lib/utils/cookies.js @@ -0,0 +1,8 @@ +import CookiesBuilder from 'js-cookie'; + +// set default path for cookies +const Cookies = CookiesBuilder.withAttributes({ + path: gon.relative_url_root || '/', +}); + +export default Cookies; diff --git a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js index 095a29a2eff..05f34db662a 100644 --- a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js @@ -7,7 +7,7 @@ import { formatDate } from './date_format_utility'; * * see https://github.com/hustcc/timeago.js/tree/v3.0.0/locales */ -const timeagoLanguageCode = languageCode().replace(/-/g, '_'); +export const timeagoLanguageCode = languageCode().replace(/-/g, '_'); /** * Registers timeago locales diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index b52a736f153..4262329aae7 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -90,6 +90,20 @@ export const getParents = (element) => { return parents; }; +export const getParentByTagName = (element, tagName) => { + let parent = element.parentNode; + + do { + if (parent.nodeName?.toLowerCase() === tagName?.toLowerCase()) { + return parent; + } + + parent = parent.parentElement; + } while (parent); + + return undefined; +}; + /** * This method takes a HTML element and an object of attributes * to save repeated calls to `setAttribute` when multiple diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index 6b9be34235b..c5190592bb6 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -22,6 +22,7 @@ const httpStatusCodes = { METHOD_NOT_ALLOWED: 405, CONFLICT: 409, GONE: 410, + PAYLOAD_TOO_LARGE: 413, UNPROCESSABLE_ENTITY: 422, TOO_MANY_REQUESTS: 429, INTERNAL_SERVER_ERROR: 500, diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 52fa90c7791..243de48948c 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -14,6 +14,8 @@ const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl // detect a horizontal rule that might be mistaken for a list item (not full pattern for an <hr>) const HR_PATTERN = /^((\s{0,3}-+\s*-+\s*-+\s*[\s-]*)|(\s{0,3}\*+\s*\*+\s*\*+\s*[\s*]*))$/; +let compositioningNoteText = false; + function selectedText(text, textarea) { return text.substring(textarea.selectionStart, textarea.selectionEnd); } @@ -363,10 +365,11 @@ function continueOlText(result, nextLineResult) { } function handleContinueList(e, textArea) { - if (!gon.features?.markdownContinueLists) return; if (!(e.key === 'Enter')) return; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; if (textArea.selectionStart !== textArea.selectionEnd) return; + // prevent unintended line breaks were inserted using Japanese IME on MacOS + if (compositioningNoteText) return; const currentLine = lineBefore(textArea.value, textArea, false); const result = currentLine.match(LIST_LINE_HEAD_PATTERN); @@ -420,6 +423,14 @@ export function keypressNoteText(e) { handleSurroundSelectedText(e, textArea); } +export function compositionStartNoteText() { + compositioningNoteText = true; +} + +export function compositionEndNoteText() { + compositioningNoteText = false; +} + export function updateTextForToolbarBtn($toolbarBtn) { return updateText({ textArea: $toolbarBtn.closest('.md-area').find('textarea'), @@ -435,6 +446,8 @@ export function updateTextForToolbarBtn($toolbarBtn) { export function addMarkdownListeners(form) { $('.markdown-area', form) .on('keydown', keypressNoteText) + .on('compositionstart', compositionStartNoteText) + .on('compositionend', compositionEndNoteText) .each(function attachTextareaShortcutHandlers() { Shortcuts.initMarkdownEditorShortcuts($(this), updateTextForToolbarBtn); }); @@ -474,6 +487,8 @@ export function addEditorMarkdownListeners(editor) { export function removeMarkdownListeners(form) { $('.markdown-area', form) .off('keydown', keypressNoteText) + .off('compositionstart', compositionStartNoteText) + .off('compositionend', compositionEndNoteText) .each(function removeTextareaShortcutHandlers() { Shortcuts.removeMarkdownEditorShortcuts($(this)); }); diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 419afa0a0a9..dad9cbcb6f6 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -6,6 +6,10 @@ import { } from '~/lib/utils/constants'; import { allSingleQuotes } from '~/lib/utils/regexp'; +export const COLON = ':'; +export const HYPHEN = '-'; +export const NEWLINE = '\n'; + /** * Adds a , to a string composed by numbers, at every 3 chars. * diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 335cd6a16e5..ff60fd2aecb 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -420,6 +420,19 @@ export function isSafeURL(url) { } /** + * Returns the sanitized url when not safe + * + * @param {String} url + * @returns {String} + */ +export function sanitizeUrl(url) { + if (!isSafeURL(url)) { + return 'about:blank'; + } + return url; +} + +/** * Returns a normalized url * * https://gitlab.com/foo/../baz => https://gitlab.com/baz diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js index 54f69ef8e1b..bd000bb26fe 100644 --- a/app/assets/javascripts/lib/utils/users_cache.js +++ b/app/assets/javascripts/lib/utils/users_cache.js @@ -35,6 +35,17 @@ class UsersCache extends Cache { // missing catch is intentional, error handling depends on use case } + updateById(userId, data) { + if (!this.hasData(userId)) { + return; + } + + this.internalStorage[userId] = { + ...this.internalStorage[userId], + ...data, + }; + } + retrieveStatusById(userId) { if (this.hasData(userId) && this.get(userId).status) { return Promise.resolve(this.get(userId).status); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 8fc54be9c28..2f3cdc525a7 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -1,7 +1,6 @@ /* global $ */ import jQuery from 'jquery'; -import Cookies from 'js-cookie'; // bootstrap webpack, common libs, polyfills, and behaviors import './webpack'; @@ -178,9 +177,6 @@ initUserTracking(); initLayoutNav(); initAlertHandler(); -// Set the default path for all cookies to GitLab's root directory -Cookies.defaults.path = gon.relative_url_root || '/'; - // `hashchange` is not triggered when link target is already in window.location $body.on('click', 'a[href^="#"]', function clickHashLinkCallback() { const href = this.getAttribute('href'); @@ -199,7 +195,11 @@ $body.on('click', 'a[href^="#"]', function clickHashLinkCallback() { * Quick fix: Get rid of jQuery for this implementation */ const isBoardsPage = /(projects|groups):boards:show/.test(document.body.dataset.page); -if (!isBoardsPage && (bootstrapBreakpoint === 'sm' || bootstrapBreakpoint === 'xs')) { +if ( + !isBoardsPage && + !window.gon?.features?.movedMrSidebar && + (bootstrapBreakpoint === 'sm' || bootstrapBreakpoint === 'xs') +) { const $rightSidebar = $('aside.right-sidebar'); const $layoutPage = $('.layout-page'); diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 0b97ce7e33e..14d628e455c 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -232,12 +232,10 @@ export default { v-bind="tableAttrs.table" class="members-table" data-testid="members-table" - head-variant="white" stacked="lg" :fields="filteredAndModifiedFields" :items="members" primary-key="id" - thead-class="border-bottom" :empty-text="__('No members found')" show-empty :tbody-tr-attr="tbodyTrAttr" diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 829e2264152..960b25bb552 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -5,6 +5,7 @@ import createFlash from '~/flash'; import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; import eventHub from '~/vue_merge_request_widget/event_hub'; +import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; import axios from './lib/utils/axios_utils'; import { addDelimiter } from './lib/utils/text_utility'; import { getParameterValues, setUrlParams } from './lib/utils/url_utility'; @@ -31,8 +32,16 @@ function MergeRequest(opts) { selector: '.detail-page-description', lockVersion: this.$el.data('lockVersion'), onSuccess: (result) => { - document.querySelector('#task_status').innerText = result.task_status; - document.querySelector('#task_status_short').innerText = result.task_status_short; + const taskStatus = document.querySelector('#task_status'); + const taskStatusShort = document.querySelector('#task_status_short'); + + if (taskStatus) { + taskStatus.innerText = result.task_status; + } + + if (taskStatusShort) { + document.querySelector('#task_status_short').innerText = result.task_status_short; + } }, onError: () => { createFlash({ @@ -72,8 +81,7 @@ MergeRequest.prototype.initMRBtnListeners = function () { const wipEvent = getParameterValues('merge_request[wip_event]', url)[0]; const mobileDropdown = draftToggle.closest('.dropdown.show'); - const loader = document.createElement('span'); - loader.classList.add('gl-spinner', 'gl-mr-3'); + const loader = loadingIconForLegacyJS({ inline: true, classes: ['gl-mr-3'] }); if (mobileDropdown) { $(mobileDropdown.firstElementChild).dropdown('toggle'); @@ -90,10 +98,13 @@ MergeRequest.prototype.initMRBtnListeners = function () { MergeRequest.toggleDraftStatus(data.title, wipEvent === 'unwip'); }) .catch(() => { - draftToggle.removeAttribute('disabled'); createFlash({ message: __('Something went wrong. Please try again.'), }); + }) + .finally(() => { + draftToggle.removeAttribute('disabled'); + loader.remove(); }); }); }); @@ -145,7 +156,11 @@ MergeRequest.toggleDraftStatus = function (title, isReady) { } else { toast(__('Marked as draft. Can only be merged when marked as ready.')); } - const titleEl = document.querySelector('.merge-request .detail-page-description .title'); + const titleEl = document.querySelector( + `.merge-request .detail-page-${ + window.gon?.features?.updatedMrHeader ? 'header' : 'description' + } .title`, + ); if (titleEl) { titleEl.textContent = title; @@ -162,7 +177,9 @@ MergeRequest.toggleDraftStatus = function (title, isReady) { ); draftToggle.setAttribute('href', url); - draftToggle.textContent = isReady ? __('Mark as draft') : __('Mark as ready'); + draftToggle.querySelector('.gl-new-dropdown-item-text-wrapper').textContent = isReady + ? __('Mark as draft') + : __('Mark as ready'); }); } }; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 61f7a079d77..e02109d1fd1 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -1,5 +1,4 @@ /* eslint-disable no-new, class-methods-use-this */ -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import Vue from 'vue'; import { getCookie, isMetaClick, parseBoolean, scrollToElement } from '~/lib/utils/common_utils'; @@ -176,6 +175,8 @@ export default class MergeRequestTabs { : null; this.navbar = document.querySelector('.navbar-gitlab'); this.peek = document.getElementById('js-peek'); + this.sidebar = document.querySelector('.js-right-sidebar'); + this.pageLayout = document.querySelector('.layout-page'); this.paddingTop = 16; this.scrollPositions = {}; @@ -282,7 +283,7 @@ export default class MergeRequestTabs { if (action === 'commits') { this.loadCommits(href); - this.expandView(); + // this.hideSidebar(); this.resetViewContainer(); this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable); } else if (action === 'new') { @@ -301,13 +302,12 @@ export default class MergeRequestTabs { */ this.loadDiff(href); } - if (bp.getBreakpointSize() !== 'xl') { - this.shrinkView(); - } + // this.hideSidebar(); this.expandViewContainer(); this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable); this.commitsTab.classList.remove('active'); } else if (action === 'pipelines') { + // this.hideSidebar(); this.resetViewContainer(); this.mountPipelinesView(); } else { @@ -320,9 +320,7 @@ export default class MergeRequestTabs { notesTab.classList.add('active'); } - if (bp.getBreakpointSize() !== 'xs') { - this.expandView(); - } + // this.showSidebar(); this.resetViewContainer(); this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable); } @@ -509,19 +507,6 @@ export default class MergeRequestTabs { } } - shrinkView() { - const $gutterBtn = $('.js-sidebar-toggle:visible'); - const $expandSvg = $gutterBtn.find('.js-sidebar-expand'); - - // Wait until listeners are set - setTimeout(() => { - // Only when sidebar is expanded - if ($expandSvg.length && $expandSvg.hasClass('hidden')) { - $gutterBtn.trigger('click', [true]); - } - }, 0); - } - // Expand the issuable sidebar unless the user explicitly collapsed it expandView() { if (parseBoolean(getCookie('collapsed_gutter'))) { @@ -538,4 +523,24 @@ export default class MergeRequestTabs { } }, 0); } + + hideSidebar() { + if (!isInVueNoteablePage() || this.cachedPageLayoutClasses) return; + + this.cachedPageLayoutClasses = this.pageLayout.className; + this.pageLayout.classList.remove( + 'right-sidebar-collapsed', + 'right-sidebar-expanded', + 'page-with-icon-sidebar', + ); + this.sidebar.style.width = '0px'; + } + + showSidebar() { + if (!isInVueNoteablePage() || !this.cachedPageLayoutClasses) return; + + this.pageLayout.className = this.cachedPageLayoutClasses; + this.sidebar.style.width = ''; + delete this.cachedPageLayoutClasses; + } } diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue index d7ffdfd7c5f..59d2a2b29b3 100644 --- a/app/assets/javascripts/milestones/components/milestone_combobox.vue +++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue @@ -187,7 +187,7 @@ export default { ref="searchBox" v-model.trim="searchQuery" class="gl-m-3" - :placeholder="this.$options.translations.searchMilestones" + :placeholder="$options.translations.searchMilestones" @input="onSearchBoxInput" @keydown.enter.prevent="onSearchBoxEnter" /> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index c4392dd3748..6a85833db27 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -492,7 +492,9 @@ export default { v-if="!groupSingleEmptyState(groupData.key)" :value="groupData.panels" group="metrics-dashboard" - :component-data="{ attrs: { class: 'row mx-0 w-100' } }" + :component-data="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + attrs: { class: 'row mx-0 w-100' }, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" :disabled="!isRearrangingPanels" @input="updatePanels(groupData.key, $event)" > diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 78e3b15913a..ff8ccded83b 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -19,6 +19,7 @@ import invalidUrl from '~/lib/utils/invalid_url'; import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility'; import { __, n__ } from '~/locale'; import TrackEventDirective from '~/vue_shared/directives/track_event'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { panelTypes } from '../constants'; import { graphDataToCsv } from '../csv_export'; @@ -57,6 +58,7 @@ export default { GlTooltip: GlTooltipDirective, TrackEvent: TrackEventDirective, }, + mixins: [glFeatureFlagsMixin()], props: { clipboardText: { type: String, @@ -141,6 +143,9 @@ export default { return metrics.some(({ loading }) => loading); }, logsPathWithTimeRange() { + if (!this.glFeatures.monitorLogging) { + return null; + } const timeRange = this.zoomedTimeRange || this.timeRange; if (this.logsPath && this.logsPath !== invalidUrl && timeRange) { diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index a1377415efe..7424c011052 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -31,6 +31,8 @@ export default function initMrNotes() { const el = document.getElementById('js-vue-discussion-counter'); if (el) { + const { blocksMerge } = el.dataset; + // eslint-disable-next-line no-new new Vue({ el, @@ -40,7 +42,11 @@ export default function initMrNotes() { }, store, render(createElement) { - return createElement('discussion-counter'); + return createElement('discussion-counter', { + props: { + blocksMerge: blocksMerge === 'true', + }, + }); }, }); } diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index a9948fed3b6..4e03bed8737 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -6,7 +6,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import Autosave from '~/autosave'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import createFlash from '~/flash'; -import { statusBoxState } from '~/issuable/components/status_box.vue'; +import { badgeState } from '~/issuable/components/status_box.vue'; import httpStatusCodes from '~/lib/utils/http_status'; import { capitalizeFirstCharacter, @@ -90,9 +90,16 @@ export default { return this.getUserData.id; }, commentButtonTitle() { - return this.noteType === constants.COMMENT - ? this.$options.i18n.comment - : this.$options.i18n.startThread; + const { comment, internalComment, startThread, startInternalThread } = this.$options.i18n; + if (this.noteIsConfidential) { + return this.noteType === constants.COMMENT ? internalComment : startInternalThread; + } + return this.noteType === constants.COMMENT ? comment : startThread; + }, + textareaPlaceholder() { + return this.noteIsConfidential + ? this.$options.i18n.bodyPlaceholderInternal + : this.$options.i18n.bodyPlaceholder; }, discussionsRequireResolution() { return this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE; @@ -266,7 +273,7 @@ export default { const toggleState = this.isOpen ? this.closeIssuable : this.reopenIssuable; toggleState() - .then(() => statusBoxState.updateStatus && statusBoxState.updateStatus()) + .then(() => badgeState.updateStatus && badgeState.updateStatus()) .then(refreshUserMergeRequestCounts) .catch(() => createFlash({ @@ -371,7 +378,7 @@ export default { data-testid="comment-field" data-supports-quick-actions="true" :aria-label="$options.i18n.comment" - :placeholder="$options.i18n.bodyPlaceholder" + :placeholder="textareaPlaceholder" @keydown.up="editCurrentUserLastNote()" @keydown.meta.enter="handleEnter()" @keydown.ctrl.enter="handleEnter()" @@ -386,7 +393,7 @@ export default { data-testid="add-to-review-button" type="submit" category="primary" - variant="success" + variant="confirm" @click.prevent="handleSaveDraft()" >{{ __('Add to review') }}</gl-button > @@ -419,6 +426,7 @@ export default { class="gl-mr-3" :disabled="disableSubmitButton" :tracking-label="trackingLabel" + :is-internal-note="noteIsConfidential" :noteable-display-name="noteableDisplayName" :discussions-require-resolution="discussionsRequireResolution" @click="handleSave" diff --git a/app/assets/javascripts/notes/components/comment_type_dropdown.vue b/app/assets/javascripts/notes/components/comment_type_dropdown.vue index 30ea5d3532e..543be838920 100644 --- a/app/assets/javascripts/notes/components/comment_type_dropdown.vue +++ b/app/assets/javascripts/notes/components/comment_type_dropdown.vue @@ -32,6 +32,11 @@ export default { required: false, default: false, }, + isInternalNote: { + type: Boolean, + required: false, + default: false, + }, noteableDisplayName: { type: String, required: true, @@ -48,18 +53,43 @@ export default { isNoteTypeDiscussion() { return this.noteType === constants.DISCUSSION; }, + dropdownCommentButtonTitle() { + const { comment, internalComment } = this.$options.i18n.submitButton; + + return this.isInternalNote ? internalComment : comment; + }, + dropdownStartThreadButtonTitle() { + const { startThread, startInternalThread } = this.$options.i18n.submitButton; + + return this.isInternalNote ? startInternalThread : startThread; + }, commentButtonTitle() { - return this.noteType === constants.COMMENT - ? this.$options.i18n.comment - : this.$options.i18n.startThread; + const { comment, internalComment, startThread, startInternalThread } = this.$options.i18n; + + if (this.isInternalNote) { + return this.noteType === constants.COMMENT ? internalComment : startInternalThread; + } + return this.noteType === constants.COMMENT ? comment : startThread; }, startDiscussionDescription() { - return this.discussionsRequireResolution - ? this.$options.i18n.discussionThatNeedsResolution - : this.$options.i18n.discussion; + const { + discussionThatNeedsResolution, + internalDiscussionThatNeedsResolution, + discussion, + internalDiscussion, + } = this.$options.i18n; + + if (this.isInternalNote) { + return this.discussionsRequireResolution + ? internalDiscussionThatNeedsResolution + : internalDiscussion; + } + return this.discussionsRequireResolution ? discussionThatNeedsResolution : discussion; }, commentDescription() { - return sprintf(this.$options.i18n.submitButton.commentHelp, { + const { commentHelp, internalCommentHelp } = this.$options.i18n.submitButton; + + return sprintf(this.isInternalNote ? internalCommentHelp : commentHelp, { noteableDisplayName: this.noteableDisplayName, }); }, @@ -101,7 +131,7 @@ export default { :is-checked="isNoteTypeComment" @click.stop.prevent="setNoteTypeToComment" > - <strong>{{ $options.i18n.submitButton.comment }}</strong> + <strong>{{ dropdownCommentButtonTitle }}</strong> <p class="gl-m-0">{{ commentDescription }}</p> </gl-dropdown-item> <gl-dropdown-divider /> @@ -111,7 +141,7 @@ export default { data-qa-selector="discussion_menu_item" @click.stop.prevent="setNoteTypeToDiscussion" > - <strong>{{ $options.i18n.submitButton.startThread }}</strong> + <strong>{{ dropdownStartThreadButtonTitle }}</strong> <p class="gl-m-0">{{ startDiscussionDescription }}</p> </gl-dropdown-item> </gl-dropdown> diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index 45d97f278dc..5210d2ca287 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -98,13 +98,14 @@ export default { <template> <div class="discussion-header note-wrapper"> - <div v-once class="timeline-icon align-self-start flex-shrink-0"> + <div v-once class="timeline-icon gl-align-self-start gl-flex-shrink-0 gl-flex-shrink gl-mr-4"> <user-avatar-link v-if="author" :link-href="author.path" :img-src="author.avatar_url" :img-alt="author.name" - :img-size="40" + :img-size="32" + :img-css-classes="'gl-mr-0!' /* NOTE: this is needed only while we migrate user-avatar-image to GlAvatar (https://gitlab.com/groups/gitlab-org/-/epics/7731) */" /> </div> <div class="timeline-content w-100"> diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index 33819c78c0f..f746f7ed0ed 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective, GlIcon, GlButton, GlButtonGroup } from '@gitlab/ui'; +import { GlTooltipDirective, GlButton, GlButtonGroup } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import { __ } from '~/locale'; import discussionNavigation from '../mixins/discussion_navigation'; @@ -9,46 +9,41 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - GlIcon, GlButton, GlButtonGroup, }, mixins: [discussionNavigation], + props: { + blocksMerge: { + type: Boolean, + required: true, + }, + }, computed: { ...mapGetters([ - 'getUserData', 'getNoteableData', 'resolvableDiscussionsCount', 'unresolvedDiscussionsCount', - 'discussions', + 'allResolvableDiscussions', ]), - isLoggedIn() { - return this.getUserData.id; - }, allResolved() { return this.unresolvedDiscussionsCount === 0; }, - resolveAllDiscussionsIssuePath() { - return this.getNoteableData.create_issue_to_resolve_discussions_path; - }, - toggeableDiscussions() { - return this.discussions.filter((discussion) => !discussion.individual_note); - }, allExpanded() { - return this.toggeableDiscussions.every((discussion) => discussion.expanded); - }, - lineResolveClass() { - return this.allResolved ? 'line-resolve-btn is-active' : 'line-resolve-text'; + return this.allResolvableDiscussions.every((discussion) => discussion.expanded); }, toggleThreadsLabel() { return this.allExpanded ? __('Collapse all threads') : __('Expand all threads'); }, + resolveAllDiscussionsIssuePath() { + return this.getNoteableData.create_issue_to_resolve_discussions_path; + }, }, methods: { ...mapActions(['setExpandDiscussions']), handleExpandDiscussions() { this.setExpandDiscussions({ - discussionIds: this.toggeableDiscussions.map((discussion) => discussion.id), + discussionIds: this.allResolvableDiscussions.map((discussion) => discussion.id), expanded: !this.allExpanded, }); }, @@ -60,21 +55,61 @@ export default { <div v-if="resolvableDiscussionsCount > 0" ref="discussionCounter" - class="line-resolve-all-container full-width-mobile gl-display-flex d-sm-flex" + class="gl-display-flex discussions-counter" > - <div class="line-resolve-all"> - <span :class="lineResolveClass"> - <template v-if="allResolved"> - <gl-icon name="check-circle-filled" /> - {{ __('All threads resolved') }} - </template> - <template v-else> - {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }} - </template> - </span> + <div + class="gl-display-flex gl-align-items-center gl-pl-4 gl-rounded-base gl-mr-3" + :class="{ + 'gl-bg-orange-50': blocksMerge && !allResolved, + 'gl-bg-gray-50': !blocksMerge || allResolved, + 'gl-pr-4': allResolved, + 'gl-pr-2': !allResolved, + }" + data-testid="discussions-counter-text" + > + <template v-if="allResolved"> + {{ __('All threads resolved!') }} + </template> + <template v-else> + {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }} + <gl-button-group class="gl-ml-3"> + <gl-button + v-gl-tooltip.hover + :title="__('Jump to previous unresolved thread')" + :aria-label="__('Jump to previous unresolved thread')" + class="discussion-previous-btn gl-rounded-base! gl-px-2!" + data-track-action="click_button" + data-track-label="mr_previous_unresolved_thread" + data-track-property="click_previous_unresolved_thread_top" + icon="angle-up" + category="tertiary" + @click="jumpToPreviousDiscussion" + /> + <gl-button + v-gl-tooltip.hover + :title="__('Jump to next unresolved thread')" + :aria-label="__('Jump to next unresolved thread')" + class="discussion-next-btn gl-rounded-base! gl-px-2!" + data-track-action="click_button" + data-track-label="mr_next_unresolved_thread" + data-track-property="click_next_unresolved_thread_top" + icon="angle-down" + category="tertiary" + @click="jumpToNextDiscussion" + /> + </gl-button-group> + </template> </div> <gl-button-group> <gl-button + v-gl-tooltip + :title="toggleThreadsLabel" + :aria-label="toggleThreadsLabel" + class="toggle-all-discussions-btn" + :icon="allExpanded ? 'collapse' : 'expand'" + @click="handleExpandDiscussions" + /> + <gl-button v-if="resolveAllDiscussionsIssuePath && !allResolved" v-gl-tooltip :href="resolveAllDiscussionsIssuePath" @@ -83,26 +118,6 @@ export default { class="new-issue-for-discussion discussion-create-issue-btn" icon="issue-new" /> - <gl-button - v-if="isLoggedIn && !allResolved" - v-gl-tooltip - :title="__('Jump to next unresolved thread')" - :aria-label="__('Jump to next unresolved thread')" - class="discussion-next-btn" - data-track-action="click_button" - data-track-label="mr_next_unresolved_thread" - data-track-property="click_next_unresolved_thread_top" - icon="comment-next" - @click="jumpToNextDiscussion" - /> - <gl-button - v-gl-tooltip - :title="toggleThreadsLabel" - :aria-label="toggleThreadsLabel" - class="toggle-all-discussions-btn" - :icon="allExpanded ? 'angle-up' : 'angle-down'" - @click="handleExpandDiscussions" - /> </gl-button-group> </div> </template> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index fe17a061c0a..6c9bc4461c2 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -4,6 +4,7 @@ import { GlSafeHtmlDirective } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; +import { __ } from '~/locale'; import '~/behaviors/markdown/render_gfm'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import autosave from '../mixins/autosave'; @@ -69,6 +70,9 @@ export default { noteBody() { return this.note.note; }, + saveButtonTitle() { + return this.note.confidential ? __('Save internal note') : __('Save comment'); + }, hasSuggestion() { return this.note.suggestions && this.note.suggestions.length; }, @@ -180,6 +184,7 @@ export default { :note-id="note.id" :line="line" :note="note" + :save-button-title="saveButtonTitle" :help-page-path="helpPagePath" :discussion="discussion" :resolve-discussion="note.resolve_discussion" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index c1e763d81ee..5dd032abd72 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -8,9 +8,11 @@ import markdownField from '~/vue_shared/components/markdown/field.vue'; import eventHub from '../event_hub'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; +import { COMMENT_FORM } from '../i18n'; import CommentFieldLayout from './comment_field_layout.vue'; export default { + i18n: COMMENT_FORM, name: 'NoteForm', components: { markdownField, @@ -133,6 +135,11 @@ export default { .some((n) => n.current_user?.can_resolve_discussion) || this.isDraft ); }, + textareaPlaceholder() { + return this.discussionNote?.confidential + ? this.$options.i18n.bodyPlaceholderInternal + : this.$options.i18n.bodyPlaceholder; + }, noteHash() { if (this.noteId) { return `#note_${this.noteId}`; @@ -350,7 +357,7 @@ export default { data-qa-selector="reply_field" dir="auto" :aria-label="__('Reply to comment')" - :placeholder="__('Write a comment or drag your files here…')" + :placeholder="textareaPlaceholder" @keydown.meta.enter="handleKeySubmit()" @keydown.ctrl.enter="handleKeySubmit()" @keydown.exact.up="editMyLastNote()" diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 11b427b9346..1ad9d593ccc 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -1,6 +1,7 @@ <script> import { GlIcon, + GlBadge, GlLoadingIcon, GlTooltipDirective, GlSafeHtmlDirective as SafeHtml, @@ -10,8 +11,6 @@ import { __, s__ } from '~/locale'; import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; -import { NOTEABLE_TYPE_MAPPING } from '../constants'; - export default { safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, components: { @@ -19,6 +18,7 @@ export default { GitlabTeamMemberBadge: () => import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'), GlIcon, + GlBadge, GlLoadingIcon, UserNameWithStatus, }, @@ -111,13 +111,7 @@ export default { return this.author.name; }, noteConfidentialityTooltip() { - if ( - this.noteableType === NOTEABLE_TYPE_MAPPING.Issue || - this.noteableType === NOTEABLE_TYPE_MAPPING.MergeRequest - ) { - return s__('Notes|This comment is confidential and only visible to project members'); - } - return s__('Notes|This comment is confidential and only visible to group members'); + return s__('Notes|This internal note will always remain confidential'); }, }, mounted() { @@ -236,15 +230,16 @@ export default { </a> <time-ago-tooltip v-else ref="noteTimestamp" :time="createdAt" tooltip-placement="bottom" /> </template> - <gl-icon + <gl-badge v-if="isConfidential" v-gl-tooltip:tooltipcontainer.bottom - data-testid="confidentialIndicator" - name="eye-slash" - :size="16" + data-testid="internalNoteIndicator" + variant="warning" + size="sm" :title="noteConfidentialityTooltip" - class="gl-ml-1 gl-text-orange-700 align-middle" - /> + > + {{ __('Internal note') }} + </gl-badge> <slot name="extra-controls"></slot> <gl-loading-icon v-if="showSpinner" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 000eb3bdff3..0f5a517a4c5 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -7,7 +7,7 @@ import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; -import { s__, __ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -95,6 +95,9 @@ export default { isLoggedIn() { return isLoggedIn(); }, + commentType() { + return this.discussion.confidential ? __('internal note') : __('comment'); + }, autosaveKey() { return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id); }, @@ -104,6 +107,9 @@ export default { firstNote() { return this.discussion.notes.slice(0, 1)[0]; }, + saveButtonTitle() { + return this.discussion.confidential ? __('Reply internally') : __('Comment'); + }, shouldShowJumpToNextDiscussion() { return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion'); }, @@ -174,9 +180,15 @@ export default { }, cancelReplyForm: ignoreWhilePending(async function cancelReplyForm(shouldConfirm, isDirty) { if (shouldConfirm && isDirty) { - const msg = s__('Notes|Are you sure you want to cancel creating this comment?'); + const msg = sprintf( + s__('Notes|Are you sure you want to cancel creating this %{commentType}?'), + { commentType: this.commentType }, + ); - const confirmed = await confirmAction(msg); + const confirmed = await confirmAction(msg, { + primaryBtnText: __('Discard changes'), + cancelBtnText: __('Continue editing'), + }); if (!confirmed) { return; @@ -308,7 +320,7 @@ export default { ref="noteForm" :discussion="discussion" :line="diffLine" - save-button-title="Comment" + :save-button-title="saveButtonTitle" :autosave-key="autosaveKey" @handleFormUpdateAddToReview="addReplyToReview" @handleFormUpdate="saveReply" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index a2fbb242222..cda22b58c5b 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -108,6 +108,9 @@ export default { author() { return this.note.author; }, + commentType() { + return this.note.confidential ? __('internal note') : __('comment'); + }, classNameBindings() { return { [`note-row-${this.note.id}`]: true, @@ -246,14 +249,19 @@ export default { this.$emit('handleEdit'); }, async deleteHandler() { - const typeOfComment = this.note.isDraft ? __('pending comment') : __('comment'); + let { commentType } = this; + + if (this.note.isDraft) { + // Draft internal notes (i.e. MR review comments) are not supported. + commentType = __('pending comment'); + } - const msg = sprintf(__('Are you sure you want to delete this %{typeOfComment}?'), { - typeOfComment, + const msg = sprintf(__('Are you sure you want to delete this %{commentType}?'), { + commentType, }); const confirmed = await confirmAction(msg, { primaryBtnVariant: 'danger', - primaryBtnText: __('Delete Comment'), + primaryBtnText: this.note.confidential ? __('Delete Internal Note') : __('Delete Comment'), }); if (confirmed) { @@ -356,7 +364,9 @@ export default { isDirty, }) { if (shouldConfirm && isDirty) { - const msg = __('Are you sure you want to cancel editing this comment?'); + const msg = sprintf(__('Are you sure you want to cancel editing this %{commentType}?'), { + commentType: this.commentType, + }); const confirmed = await confirmAction(msg, { primaryBtnText: __('Cancel editing'), primaryBtnVariant: 'danger', diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js index 951fa9733d4..4c0ee81bec0 100644 --- a/app/assets/javascripts/notes/i18n.js +++ b/app/assets/javascripts/notes/i18n.js @@ -6,19 +6,26 @@ export const COMMENT_FORM = { ), note: __('Note'), comment: __('Comment'), + internalComment: __('Add internal note'), issue: __('issue'), startThread: __('Start thread'), + startInternalThread: __('Start internal thread'), mergeRequest: __('merge request'), epic: __('epic'), bodyPlaceholder: __('Write a comment or drag your files here…'), - confidential: s__('Notes|Make this comment confidential'), + bodyPlaceholderInternal: __('Write an internal note or drag your files here…'), + confidential: s__('Notes|Make this an internal note'), confidentialVisibility: s__( - 'Notes|Confidential comments are only visible to members with the role of Reporter or higher', + 'Notes|Internal notes are only visible to the author, assignees, and members with the role of Reporter or higher', ), discussionThatNeedsResolution: __( 'Discuss a specific suggestion or question that needs to be resolved.', ), + internalDiscussionThatNeedsResolution: __( + 'Discuss a specific suggestion or question internally that needs to be resolved.', + ), discussion: __('Discuss a specific suggestion or question.'), + internalDiscussion: __('Discuss a specific suggestion or question internally.'), actionButtonWithNote: __('%{actionText} & %{openOrClose} %{noteable}'), actionButton: { withNote: { @@ -32,7 +39,10 @@ export const COMMENT_FORM = { }, submitButton: { startThread: __('Start thread'), + startInternalThread: __('Start internal thread'), comment: __('Comment'), + internalComment: __('Add internal note'), commentHelp: __('Add a general comment to this %{noteableDisplayName}.'), + internalCommentHelp: __('Add a confidential internal note to this %{noteableDisplayName}.'), }, }; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 204e704e504..0cfc17a6ae9 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -17,6 +17,7 @@ import { mergeUrlParams } from '~/lib/utils/url_utility'; import sidebarTimeTrackingEventHub from '~/sidebar/event_hub'; import TaskList from '~/task_list'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; import * as constants from '../constants'; import eventHub from '../event_hub'; import * as types from './mutation_types'; @@ -369,7 +370,14 @@ export const saveNote = ({ commit, dispatch }, noteData) => { } const processQuickActions = (res) => { - const { errors: { commands_only: message } = { commands_only: null } } = res; + const { + errors: { commands_only: commandsOnly, command_names: commandNames } = { + commands_only: null, + command_names: [], + }, + } = res; + let message = commandsOnly; + /* The following reply means that quick actions have been successfully applied: @@ -387,6 +395,13 @@ export const saveNote = ({ commit, dispatch }, noteData) => { confidentialWidget.setConfidentiality(); } + const commands = ['approve', 'merge', 'assign_reviewer', 'assign']; + const commandUpdatesAttentionRequest = commandNames[0].some((c) => commands.includes(c)); + + if (commandUpdatesAttentionRequest && SidebarStore.singleton.currentUserHasAttention) { + message = sprintf(__('%{message}. Your attention request was removed.'), { message }); + } + $('.js-gfm-input').trigger('clear-commands-cache.atwho'); createFlash({ diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index a710ac0ccf5..1fe82d96435 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -1,5 +1,6 @@ import { flattenDeep, clone } from 'lodash'; -import { statusBoxState } from '~/issuable/components/status_box.vue'; +import { match } from '~/diffs/utils/diff_file'; +import { badgeState } from '~/issuable/components/status_box.vue'; import { isInMRPage } from '~/lib/utils/common_utils'; import * as constants from '../constants'; import { collapseSystemNotes } from './collapse_utils'; @@ -84,8 +85,7 @@ export const getBlockedByIssues = (state) => state.noteableData.blocked_by_issue export const userCanReply = (state) => Boolean(state.noteableData.current_user.can_create_note); -export const openState = (state) => - isInMRPage() ? statusBoxState.state : state.noteableData.state; +export const openState = (state) => (isInMRPage() ? badgeState.state : state.noteableData.state); export const getUserData = (state) => state.userData || {}; @@ -179,29 +179,42 @@ export const unresolvedDiscussionsIdsByDate = (state, getters) => // Sorts the array of resolvable yet unresolved discussions by // comparing file names first. If file names are the same, compares // line numbers. -export const unresolvedDiscussionsIdsByDiff = (state, getters) => - getters.allResolvableDiscussions +export const unresolvedDiscussionsIdsByDiff = (state, getters, allState) => { + const authoritativeFiles = allState.diffs.diffFiles; + + return getters.allResolvableDiscussions .filter((d) => !d.resolved && d.active) .sort((a, b) => { + let order = 0; + if (!a.diff_file || !b.diff_file) { - return 0; + return order; } - // Get file names comparison result - const filenameComparison = a.diff_file.file_path.localeCompare(b.diff_file.file_path); + const authoritativeA = authoritativeFiles.find((source) => + match({ fileA: source, fileB: a.diff_file, mode: 'mr' }), + ); + const authoritativeB = authoritativeFiles.find((source) => + match({ fileA: source, fileB: b.diff_file, mode: 'mr' }), + ); + + if (authoritativeA && authoritativeB) { + order = authoritativeA.order - authoritativeB.order; + } // Get the line numbers, to compare within the same file const aLines = [a.position.new_line, a.position.old_line]; const bLines = [b.position.new_line, b.position.old_line]; - return filenameComparison < 0 || - (filenameComparison === 0 && + return order < 0 || + (order === 0 && // .max() because one of them might be zero (if removed/added) Math.max(aLines[0], aLines[1]) < Math.max(bLines[0], bLines[1])) ? -1 : 1; }) .map((d) => d.id); +}; export const resolvedDiscussionCount = (state, getters) => { const resolvedMap = getters.resolvedDiscussionsById; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue index 7a8a1bbcf09..2da8ca2d8a8 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue @@ -83,11 +83,13 @@ export default { modal-id="delete-tag-modal" ok-variant="danger" size="sm" - :action-primary="{ + :action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { text: __('Delete'), attributes: [{ variant: 'danger' }, { disabled: disablePrimaryButton }], - }" - :action-cancel="{ text: __('Cancel') }" + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :action-cancel="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + text: __('Cancel'), + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @primary="$emit('confirmDelete')" @cancel="$emit('cancelDelete')" @change="projectPath = ''" diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue index 7659ba5f9ea..9e8eb92d87a 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue @@ -168,7 +168,9 @@ export default { <div> <persisted-search class="gl-mb-5" - :sortable-fields="[$options.searchConfig.NAME_SORT_FIELD]" + :sortable-fields="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ + $options.searchConfig.NAME_SORT_FIELD, + ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" :default-order="$options.searchConfig.NAME_SORT_FIELD.orderBy" default-sort="asc" @update="handleSearchUpdate" diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue index 1f52e319ad0..3ae69731537 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue @@ -1,7 +1,9 @@ <script> -import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlIcon, GlPopover, GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { timeTilRun } from '../../utils'; import { - CLEANUP_TIMED_OUT_ERROR_MESSAGE, + PARTIAL_CLEANUP_CONTINUE_MESSAGE, CLEANUP_STATUS_SCHEDULED, CLEANUP_STATUS_ONGOING, CLEANUP_STATUS_UNFINISHED, @@ -15,9 +17,9 @@ export default { name: 'CleanupStatus', components: { GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, + GlPopover, + GlLink, + GlSprintf, }, props: { status: { @@ -29,12 +31,17 @@ export default { ); }, }, + expirationPolicy: { + type: Object, + default: () => ({}), + required: false, + }, }, i18n: { CLEANUP_STATUS_SCHEDULED, CLEANUP_STATUS_ONGOING, CLEANUP_STATUS_UNFINISHED, - CLEANUP_TIMED_OUT_ERROR_MESSAGE, + PARTIAL_CLEANUP_CONTINUE_MESSAGE, }, computed: { showStatus() { @@ -46,26 +53,57 @@ export default { statusText() { return this.$options.i18n[`CLEANUP_STATUS_${this.status}`]; }, - expireIconClass() { - return this.failedDelete ? 'gl-text-orange-500' : ''; + calculatedTimeTilNextRun() { + return timeTilRun(this.expirationPolicy?.next_run); }, }, + statusPopoverOptions: { + triggers: 'hover', + placement: 'top', + }, + cleanupPolicyHelpPage: helpPagePath( + 'user/packages/container_registry/reduce_container_registry_storage.html', + { anchor: 'how-the-cleanup-policy-works' }, + ), }; </script> <template> - <div v-if="showStatus" class="gl-display-inline-flex gl-align-items-center"> - <gl-icon name="expire" data-testid="main-icon" :class="expireIconClass" /> + <div + v-if="showStatus" + id="status-popover-container" + class="gl-display-inline-flex gl-align-items-center" + > + <div class="gl-display-inline-flex gl-align-items-center"> + <gl-icon name="expire" data-testid="main-icon" /> + </div> <span class="gl-mx-2"> {{ statusText }} </span> <gl-icon v-if="failedDelete" - v-gl-tooltip="{ title: $options.i18n.CLEANUP_TIMED_OUT_ERROR_MESSAGE }" + id="status-info" :size="14" - class="gl-text-black-normal" + class="gl-text-gray-500" data-testid="extra-info" - name="information" + name="information-o" /> + <gl-popover + target="status-info" + container="status-popover-container" + v-bind="$options.statusPopoverOptions" + > + <template #title> + {{ $options.i18n.CLEANUP_STATUS_UNFINISHED }} + </template> + <gl-sprintf :message="$options.i18n.PARTIAL_CLEANUP_CONTINUE_MESSAGE"> + <template #time>{{ calculatedTimeTilNextRun }}</template + ><template #link="{ content }" + ><gl-link :href="$options.cleanupPolicyHelpPage" class="gl-font-sm" target="_blank">{{ + content + }}</gl-link></template + > + </gl-sprintf> + </gl-popover> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue index 5bd13322ebb..6f1f67e251f 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue @@ -22,6 +22,11 @@ export default { type: Object, required: true, }, + expirationPolicy: { + type: Object, + default: () => ({}), + required: false, + }, }, computed: { showPagination() { @@ -38,6 +43,7 @@ export default { :key="index" :item="listItem" :metadata-loading="metadataLoading" + :expiration-policy="expirationPolicy" @delete="$emit('delete', $event)" /> <div class="gl-display-flex gl-justify-content-center"> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue index 484903354e8..d76a8245b63 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue @@ -6,12 +6,10 @@ import { n__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { - ASYNC_DELETE_IMAGE_ERROR_MESSAGE, LIST_DELETE_BUTTON_DISABLED, LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION, REMOVE_REPOSITORY_LABEL, ROW_SCHEDULED_FOR_DELETION, - CLEANUP_TIMED_OUT_ERROR_MESSAGE, IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS, IMAGE_MIGRATING_STATE, @@ -45,6 +43,11 @@ export default { default: false, required: false, }, + expirationPolicy: { + type: Object, + default: () => ({}), + required: false, + }, }, i18n: { REMOVE_REPOSITORY_LABEL, @@ -73,15 +76,6 @@ export default { this.item.tagsCount, ); }, - warningIconText() { - if (this.failedDelete) { - return ASYNC_DELETE_IMAGE_ERROR_MESSAGE; - } - if (this.item.expirationPolicyStartedAt) { - return CLEANUP_TIMED_OUT_ERROR_MESSAGE; - } - return null; - }, imageName() { return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`; }, @@ -140,6 +134,7 @@ export default { v-if="item.expirationPolicyCleanupStatus" class="ml-2" :status="item.expirationPolicyCleanupStatus" + :expiration-policy="expirationPolicy" /> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue index 154e176dc6e..4ffd8390e4d 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue @@ -7,7 +7,6 @@ import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import { CONTAINER_REGISTRY_TITLE, - LIST_INTRO_TEXT, EXPIRATION_POLICY_WILL_RUN_IN, EXPIRATION_POLICY_DISABLED_TEXT, SET_UP_CLEANUP, @@ -87,19 +86,12 @@ export default { ? sprintf(EXPIRATION_POLICY_WILL_RUN_IN, { time: this.timeTillRun }) : EXPIRATION_POLICY_DISABLED_TEXT; }, - infoMessages() { - return [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }]; - }, }, }; </script> <template> - <title-area - :title="$options.i18n.CONTAINER_REGISTRY_TITLE" - :info-messages="infoMessages" - :metadata-loading="metadataLoading" - > + <title-area :title="$options.i18n.CONTAINER_REGISTRY_TITLE" :metadata-loading="metadataLoading"> <template #right-actions> <slot name="commands"></slot> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js index 3c7f7ca9aa8..2a58933cd64 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js @@ -87,7 +87,7 @@ export const CLEANUP_DISABLED_TOOLTIP = s__( export const CLEANUP_STATUS_SCHEDULED = s__('ContainerRegistry|Cleanup will run soon'); export const CLEANUP_STATUS_ONGOING = s__('ContainerRegistry|Cleanup is ongoing'); -export const CLEANUP_STATUS_UNFINISHED = s__('ContainerRegistry|Cleanup timed out'); +export const CLEANUP_STATUS_UNFINISHED = s__('ContainerRegistry|Partial cleanup complete'); export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while scheduling the image for deletion.', diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js index e584da23edb..9d0ecfd2dcb 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js @@ -10,7 +10,7 @@ export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not dele export const DELETE_ALERT_LINK_TEXT = s__( 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}', ); -export const CLEANUP_TIMED_OUT_ERROR_MESSAGE = s__( - 'ContainerRegistry|Cleanup timed out before it could delete all tags', +export const PARTIAL_CLEANUP_CONTINUE_MESSAGE = s__( + 'ContainerRegistry|The cleanup will continue within %{time}. %{linkStart}Learn more%{linkEnd}', ); export const SET_UP_CLEANUP = s__('ContainerRegistry|Set up cleanup'); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js index c7022d6070f..ceaf8a65a10 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js @@ -8,9 +8,6 @@ export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection e export const CONNECTION_ERROR_MESSAGE = s__( `ContainerRegistry|We are having trouble connecting to the Container Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`, ); -export const LIST_INTRO_TEXT = s__( - `ContainerRegistry|With the GitLab Container Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`, -); export const LIST_DELETE_BUTTON_DISABLED = s__( 'ContainerRegistry|Missing or insufficient permission, delete button disabled', ); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql index 916740f41b8..e2036d9e63d 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql @@ -1,4 +1,4 @@ -query getContainerRepositoryDetails($id: ID!) { +query getContainerRepositoryDetails($id: ContainerRepositoryID!) { containerRepository(id: $id) { id name diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql index f1f67b98407..1faa9dec795 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql @@ -1,4 +1,4 @@ -query getContainerRepositoryMetadata($id: ID!) { +query getContainerRepositoryMetadata($id: ContainerRepositoryID!) { containerRepository(id: $id) { id tagsCount diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql index 8c577cc7b17..e57ac2a9efe 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql @@ -1,7 +1,7 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" query getContainerRepositoryTags( - $id: ID! + $id: ContainerRepositoryID! $first: Int $last: Int $after: String diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue index d1cab406984..c1bd71de646 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue @@ -336,6 +336,7 @@ export default { :images="images" :metadata-loading="$apollo.queries.additionalDetails.loading" :page-info="pageInfo" + :expiration-policy="config.expirationPolicy" @delete="deleteImage" @prev-page="fetchPreviousPage" @next-page="fetchNextPage" @@ -370,7 +371,10 @@ export default { ref="deleteModal" size="sm" modal-id="delete-image-modal" - :action-primary="{ text: __('Remove'), attributes: { variant: 'danger' } }" + :action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + text: __('Remove'), + attributes: { variant: 'danger' }, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @primary="doDelete" @cancel="track('cancel_delete')" > diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js new file mode 100644 index 00000000000..ffdaf9f2f17 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js @@ -0,0 +1,8 @@ +import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility'; + +export const timeTilRun = (time) => { + if (!time) return ''; + + const difference = calculateRemainingMilliseconds(time); + return approximateDuration(difference / 1000); +}; diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue index 67c2ca02d20..1faff1ff4de 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue @@ -16,10 +16,7 @@ import Api from '~/api'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import ManifestsList from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue'; -import { - DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, - DEPENDENCY_PROXY_DOCS_PATH, -} from '~/packages_and_registries/settings/group/constants'; +import { DEPENDENCY_PROXY_DOCS_PATH } from '~/packages_and_registries/settings/group/constants'; import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants'; import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql'; @@ -42,11 +39,8 @@ export default { directives: { GlModalDirective, }, - inject: ['groupPath', 'groupId', 'dependencyProxyAvailable', 'noManifestsIllustration'], + inject: ['groupPath', 'groupId', 'noManifestsIllustration'], i18n: { - proxyNotAvailableText: s__( - 'DependencyProxy|Dependency Proxy feature is limited to public groups for now.', - ), proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'), copyImagePrefixText: s__('DependencyProxy|Copy prefix'), blobCountAndSize: s__('DependencyProxy|Contains %{count} blobs of images (%{size})'), @@ -80,32 +74,20 @@ export default { apollo: { group: { query: getDependencyProxyDetailsQuery, - skip() { - return !this.dependencyProxyAvailable; - }, variables() { return this.queryVariables; }, }, }, computed: { - infoMessages() { - return [ - { - text: DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, - link: DEPENDENCY_PROXY_DOCS_PATH, - }, - ]; - }, - queryVariables() { return { fullPath: this.groupPath, first: GRAPHQL_PAGE_SIZE }; }, pageInfo() { - return this.group.dependencyProxyManifests.pageInfo; + return this.group.dependencyProxyManifests?.pageInfo; }, manifests() { - return this.group.dependencyProxyManifests.nodes; + return this.group.dependencyProxyManifests?.nodes; }, modalTitleWithCount() { return sprintf( @@ -132,7 +114,10 @@ export default { ); }, showDeleteDropdown() { - return this.group.dependencyProxyBlobCount > 0; + return this.group.dependencyProxyManifests?.nodes.length > 0; + }, + showDependencyProxyImagePrefix() { + return this.group.dependencyProxyImagePrefix?.length > 0; }, }, methods: { @@ -181,7 +166,7 @@ export default { > {{ deleteCacheAlertMessage }} </gl-alert> - <title-area :title="$options.i18n.pageTitle" :info-messages="infoMessages"> + <title-area :title="$options.i18n.pageTitle"> <template v-if="showDeleteDropdown" #right-actions> <gl-dropdown icon="ellipsis_v" @@ -198,41 +183,34 @@ export default { </gl-dropdown> </template> </title-area> - <gl-alert - v-if="!dependencyProxyAvailable" - :dismissible="false" - data-testid="proxy-not-available" - > - {{ $options.i18n.proxyNotAvailableText }} - </gl-alert> - <gl-skeleton-loader v-else-if="$apollo.queries.group.loading" /> - - <div v-else data-testid="main-area"> - <gl-form-group :label="$options.i18n.proxyImagePrefix"> - <gl-form-input-group - readonly - :value="group.dependencyProxyImagePrefix" - class="gl-layout-w-limited" - data-testid="proxy-url" - > - <template #append> - <clipboard-button - :text="group.dependencyProxyImagePrefix" - :title="$options.i18n.copyImagePrefixText" - /> - </template> - </gl-form-input-group> - <template #description> - <span data-qa-selector="dependency_proxy_count" data-testid="proxy-count"> - <gl-sprintf :message="$options.i18n.blobCountAndSize"> - <template #count>{{ group.dependencyProxyBlobCount }}</template> - <template #size>{{ group.dependencyProxyTotalSize }}</template> - </gl-sprintf> - </span> + <gl-form-group v-if="showDependencyProxyImagePrefix" :label="$options.i18n.proxyImagePrefix"> + <gl-form-input-group + readonly + :value="group.dependencyProxyImagePrefix" + class="gl-layout-w-limited" + data-testid="proxy-url" + > + <template #append> + <clipboard-button + :text="group.dependencyProxyImagePrefix" + :title="$options.i18n.copyImagePrefixText" + /> </template> - </gl-form-group> + </gl-form-input-group> + <template #description> + <span data-qa-selector="dependency_proxy_count" data-testid="proxy-count"> + <gl-sprintf :message="$options.i18n.blobCountAndSize"> + <template #count>{{ group.dependencyProxyBlobCount }}</template> + <template #size>{{ group.dependencyProxyTotalSize }}</template> + </gl-sprintf> + </span> + </template> + </gl-form-group> + <gl-skeleton-loader v-if="$apollo.queries.group.loading" /> + + <div v-else data-testid="main-area"> <manifests-list v-if="manifests && manifests.length" :manifests="manifests" diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue index 78880b6e3f4..1bbd0c32dc4 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue @@ -1,5 +1,6 @@ <script> -import { GlSprintf } from '@gitlab/ui'; +import { GlIcon, GlSprintf } from '@gitlab/ui'; +import { MANIFEST_PENDING_DESTRUCTION_STATUS } from '~/packages_and_registries/dependency_proxy/constants'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { s__ } from '~/locale'; @@ -7,6 +8,7 @@ import { s__ } from '~/locale'; export default { name: 'ManifestRow', components: { + GlIcon, GlSprintf, ListItem, TimeagoTooltip, @@ -24,17 +26,31 @@ export default { version() { return this.manifest?.imageName.split(':')[1]; }, + isErrorStatus() { + return this.manifest?.status === MANIFEST_PENDING_DESTRUCTION_STATUS; + }, + disabledRowStyle() { + return this.isErrorStatus ? 'gl-font-weight-normal gl-text-gray-500' : ''; + }, }, i18n: { cachedAgoMessage: s__('DependencyProxy|Cached %{time}'), + scheduledForDeletion: s__('DependencyProxy|Scheduled for deletion'), }, }; </script> <template> - <list-item> - <template #left-primary> {{ name }} </template> - <template #left-secondary> {{ version }} </template> + <list-item :disabled="isErrorStatus"> + <template #left-primary> + <span :class="disabledRowStyle">{{ name }}</span> + </template> + <template #left-secondary> + {{ version }} + <span v-if="isErrorStatus" class="gl-ml-4" data-testid="status" + ><gl-icon name="clock" /> {{ $options.i18n.scheduledForDeletion }}</span + > + </template> <template #right-primary> </template> <template #right-secondary> <timeago-tooltip :time="manifest.createdAt" data-testid="cached-message"> diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js index 3c6ede6fdce..fdad69204ba 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js @@ -1 +1,2 @@ export const GRAPHQL_PAGE_SIZE = 20; +export const MANIFEST_PENDING_DESTRUCTION_STATUS = 'PENDING_DESTRUCTION'; diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql index 5c43b10a5e3..c1597625964 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql @@ -20,6 +20,7 @@ query getDependencyProxyDetails( id createdAt imageName + status } pageInfo { ...PageInfo diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js index dc73470e07d..14789aafdb7 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; import app from '~/packages_and_registries/dependency_proxy/app.vue'; import { apolloProvider } from '~/packages_and_registries/dependency_proxy/graphql'; import Translate from '~/vue_shared/translate'; @@ -11,12 +10,11 @@ export const initDependencyProxyApp = () => { if (!el) { return null; } - const { dependencyProxyAvailable, ...dataset } = el.dataset; + const { ...dataset } = el.dataset; return new Vue({ el, apolloProvider, provide: { - dependencyProxyAvailable: parseBoolean(dependencyProxyAvailable), ...dataset, }, render(createElement) { diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue index f198d2e1bfa..425fb4596fd 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue @@ -191,7 +191,10 @@ export default { <package-list-row v-for="v in packageEntity.versions" :key="v.id" - :package-entity="{ name: packageEntity.name, ...v }" + :package-entity="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + name: packageEntity.name, + ...v, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" :package-link="v.id.toString()" :disable-delete="true" :show-package-type="false" diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue index c611f92036d..d3c38da1531 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue @@ -33,7 +33,7 @@ export default { <registry-search :filter="filter" :sorting="sorting" - :tokens="[]" + :tokens="[] /* eslint-disable-line @gitlab/vue-no-new-non-primitive-in-template */" :sortable-fields="sortableFields" @sorting:changed="updateSorting" @filter:changed="setFilter" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue index 118c509828c..f5946797626 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlSprintf, GlBadge, GlResizeObserverDirective } from '@gitlab/ui'; +import { GlSprintf, GlBadge, GlResizeObserverDirective } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; @@ -14,7 +14,6 @@ export default { name: 'PackageTitle', components: { TitleArea, - GlIcon, GlSprintf, PackageTags, MetadataItem, @@ -84,7 +83,6 @@ export default { data-qa-selector="package_title" > <template #sub-header> - <gl-icon name="eye" class="gl-mr-3" /> <span data-testid="sub-header"> <gl-sprintf :message="$options.i18n.packageInfo"> <template #version> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue index 6222c2e73d7..04faff1a75b 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -1,5 +1,12 @@ <script> -import { GlButton, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui'; +import { + GlDropdown, + GlDropdownItem, + GlIcon, + GlSprintf, + GlTooltipDirective, + GlTruncate, +} from '@gitlab/ui'; import { s__, __ } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { @@ -17,7 +24,9 @@ import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { name: 'PackageListRow', components: { - GlButton, + GlDropdown, + GlDropdownItem, + GlIcon, GlSprintf, GlTruncate, PackageTags, @@ -50,31 +59,42 @@ export default { pipelineUser() { return this.pipeline?.user?.name; }, - showWarningIcon() { + errorStatusRow() { return this.packageEntity.status === PACKAGE_ERROR_STATUS; }, showTags() { return Boolean(this.packageEntity.tags?.nodes?.length); }, - disabledRow() { + nonDefaultRow() { return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS; }, routerLinkEvent() { - return this.disabledRow ? '' : 'click'; + return this.nonDefaultRow ? '' : 'click'; + }, + errorPackageStyle() { + return { + 'gl-text-red-500': this.errorStatusRow, + 'gl-font-weight-normal': this.errorStatusRow, + }; }, }, i18n: { erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'), createdAt: __('Created %{timestamp}'), + deletePackage: s__('PackageRegistry|Delete package'), + errorPublishing: s__('PackageRegistry|Error publishing'), + warning: __('Warning'), + moreActions: __('More actions'), }, }; </script> <template> - <list-item data-qa-selector="package_row" :disabled="disabledRow"> + <list-item data-qa-selector="package_row"> <template #left-primary> <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"> <router-link + :class="errorPackageStyle" class="gl-text-body gl-min-w-0" data-testid="details-link" data-qa-selector="package_link" @@ -84,16 +104,6 @@ export default { <gl-truncate :text="packageEntity.name" /> </router-link> - <gl-button - v-if="showWarningIcon" - v-gl-tooltip="{ title: $options.i18n.erroredPackageText }" - class="gl-hover-bg-transparent!" - icon="warning" - category="tertiary" - data-testid="warning-icon" - :aria-label="__('Warning')" - /> - <package-tags v-if="showTags" class="gl-ml-3" @@ -104,7 +114,7 @@ export default { </div> </template> <template #left-secondary> - <div class="gl-display-flex" data-testid="left-secondary-infos"> + <div v-if="!errorStatusRow" class="gl-display-flex" data-testid="left-secondary-infos"> <span>{{ packageEntity.version }}</span> <div v-if="pipelineUser" class="gl-display-none gl-sm-display-flex gl-ml-2"> @@ -120,9 +130,19 @@ export default { <package-path v-if="isGroupPage" :path="packageEntity.project.fullPath" - :disabled="disabledRow" + :disabled="nonDefaultRow" /> </div> + <div v-else> + <gl-icon + v-gl-tooltip="{ title: $options.i18n.erroredPackageText }" + name="warning" + class="gl-text-red-500" + :aria-label="$options.i18n.warning" + data-testid="warning-icon" + /> + <span class="gl-text-red-500">{{ $options.i18n.errorPublishing }}</span> + </div> </template> <template #right-primary> @@ -139,16 +159,22 @@ export default { </span> </template> - <template v-if="!disabledRow" #right-action> - <gl-button - data-testid="action-delete" - icon="remove" - category="secondary" - variant="danger" - :title="s__('PackageRegistry|Remove package')" - :aria-label="s__('PackageRegistry|Remove package')" - @click="$emit('packageToDelete', packageEntity)" - /> + <template v-if="packageEntity.canDestroy" #right-action> + <gl-dropdown + data-testid="delete-dropdown" + icon="ellipsis_v" + :text="$options.i18n.moreActions" + :text-sr-only="true" + category="tertiary" + no-caret + > + <gl-dropdown-item + data-testid="action-delete" + variant="danger" + @click="$emit('packageToDelete', packageEntity)" + >{{ $options.i18n.deletePackage }}</gl-dropdown-item + > + </gl-dropdown> </template> </list-item> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue index bf41c36e09b..440e11a99f2 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue @@ -27,21 +27,15 @@ export default { packageAmountText() { return n__(`%d Package`, `%d Packages`, this.count); }, - infoMessages() { - return [{ text: this.$options.i18n.LIST_INTRO_TEXT, link: this.helpUrl }]; - }, }, i18n: { LIST_TITLE_TEXT: s__('PackageRegistry|Package Registry'), - LIST_INTRO_TEXT: s__( - 'PackageRegistry|Publish and share packages for a variety of common package managers. %{docLinkStart}More information%{docLinkEnd}', - ), }, }; </script> <template> - <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages"> + <title-area :title="$options.i18n.LIST_TITLE_TEXT"> <template #metadata-amount> <metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" /> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue index 298ed9bccdb..1aff23bc112 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue @@ -1,18 +1,20 @@ <script> -import { GlModal, GlSprintf, GlKeysetPagination } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlAlert, GlModal, GlSprintf, GlKeysetPagination } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; import { DELETE_PACKAGE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_TRACKING_ACTION, + PACKAGE_ERROR_STATUS, } from '~/packages_and_registries/package_registry/constants'; import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils'; import Tracking from '~/tracking'; export default { components: { + GlAlert, GlKeysetPagination, GlModal, GlSprintf, @@ -40,6 +42,7 @@ export default { data() { return { itemToBeDeleted: null, + errorPackages: [], }; }, computed: { @@ -70,6 +73,24 @@ export default { } }, }, + errorTitleAlert() { + return sprintf( + s__('PackageRegistry|There was an error publishing a %{packageName} package'), + { packageName: this.errorPackages[0].name }, + ); + }, + showErrorPackageAlert() { + return this.errorPackages.length > 0; + }, + }, + watch: { + list(newVal) { + this.errorPackages = newVal.filter((pkg) => pkg.status === PACKAGE_ERROR_STATUS); + }, + }, + created() { + this.errorPackages = + this.list.length > 0 ? this.list.filter((pkg) => pkg.status === PACKAGE_ERROR_STATUS) : []; }, methods: { setItemToBeDeleted(item) { @@ -83,12 +104,19 @@ export default { deleteItemCanceled() { this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION); }, + showConfirmationModal() { + this.setItemToBeDeleted(this.errorPackages[0]); + }, }, i18n: { deleteModalContent: s__( 'PackageRegistry|You are about to delete %{name}, this operation is irreversible, are you sure?', ), modalAction: s__('PackageRegistry|Delete package'), + errorMessageBodyAlert: s__( + 'PackageRegistry|There was a timeout and the package was not published. Delete this package and try again.', + ), + deleteThisPackage: s__('PackageRegistry|Delete this package'), }, }; </script> @@ -102,6 +130,14 @@ export default { </div> <template v-else> + <gl-alert + v-if="showErrorPackageAlert" + variant="danger" + :title="errorTitleAlert" + :primary-button-text="$options.i18n.deleteThisPackage" + @primaryAction="showConfirmationModal" + >{{ $options.i18n.errorMessageBodyAlert }}</gl-alert + > <div data-qa-selector="packages-table"> <packages-list-row v-for="packageEntity in list" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql index 66315fda9e9..b5695a01376 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql @@ -5,6 +5,7 @@ fragment PackageData on Package { packageType createdAt status + canDestroy tags { nodes { id diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql index c45cbe56e00..41b0c285fff 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql @@ -1,4 +1,4 @@ -query getPackageDetails($id: ID!) { +query getPackageDetails($id: PackagesPackageID!) { package(id: $id) { id name diff --git a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js index 85a7aeb5561..482a3ef2ead 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js @@ -21,7 +21,6 @@ export default () => { groupPath: el.dataset.groupPath, groupDependencyProxyPath: el.dataset.groupDependencyProxyPath, defaultExpanded: parseBoolean(el.dataset.defaultExpanded), - dependencyProxyAvailable: parseBoolean(el.dataset.dependencyProxyAvailable), }, render(createElement) { return createElement(SettingsApp); diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue index fd62fe144b2..a5189201112 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue @@ -13,7 +13,6 @@ import { import { DEPENDENCY_PROXY_HEADER, - DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, DEPENDENCY_PROXY_DOCS_PATH, } from '~/packages_and_registries/settings/group/constants'; @@ -28,7 +27,6 @@ export default { }, i18n: { DEPENDENCY_PROXY_HEADER, - DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, enabledProxyLabel: s__('DependencyProxy|Enable Dependency Proxy'), enabledProxyHelpText: s__( 'DependencyProxy|To see the image prefix and what is in the cache, visit the %{linkStart}Dependency Proxy%{linkEnd}', @@ -140,19 +138,6 @@ export default { data-qa-selector="dependency_proxy_settings_content" > <template #title> {{ $options.i18n.DEPENDENCY_PROXY_HEADER }} </template> - <template #description> - <span data-testid="description"> - <gl-sprintf :message="$options.i18n.DEPENDENCY_PROXY_SETTINGS_DESCRIPTION"> - <template #docLink="{ content }"> - <gl-link - data-testid="description-link" - :href="$options.links.DEPENDENCY_PROXY_DOCS_PATH" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> - </span> - </template> <template #default> <div> <gl-toggle diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue index 64c12b4be6a..f285dfc0755 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue @@ -13,7 +13,7 @@ export default { PackagesSettings, DependencyProxySettings, }, - inject: ['groupPath', 'dependencyProxyAvailable'], + inject: ['groupPath'], apollo: { group: { query: getGroupPackagesSettingsQuery, @@ -83,7 +83,6 @@ export default { /> <dependency-proxy-settings - v-if="dependencyProxyAvailable" :dependency-proxy-settings="dependencyProxySettings" :dependency-proxy-image-ttl-policy="dependencyProxyImageTtlPolicy" :is-loading="isLoading" diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js index ee922457993..0249b475e46 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -19,13 +19,10 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__( ); export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy'); -export const DEPENDENCY_PROXY_SETTINGS_DESCRIPTION = s__( - 'DependencyProxy|Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies.', -); // Parameters -export const PACKAGES_DOCS_PATH = helpPagePath('user/packages'); +export const PACKAGES_DOCS_PATH = helpPagePath('user/packages/index'); export const MAVEN_DUPLICATES_ALLOWED = 'mavenDuplicatesAllowed'; export const MAVEN_DUPLICATE_EXCEPTION_REGEX = 'mavenDuplicateExceptionRegex'; diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue index 3ef75b3ef0e..5ecacb84d65 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue +++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue @@ -112,9 +112,7 @@ export default { }, signupEnabledHelpText() { const text = sprintf( - s__( - 'ApplicationSettings|When enabled, any user visiting %{host} will be able to create an account.', - ), + s__('ApplicationSettings|Any user that visits %{host} can create an account.'), { host: this.host, }, @@ -125,7 +123,7 @@ export default { requireAdminApprovalHelpText() { const text = sprintf( s__( - 'ApplicationSettings|When enabled, any user visiting %{host} and creating an account will have to be explicitly approved by an admin before they can sign in. This setting is effective only if sign-ups are enabled.', + 'ApplicationSettings|Any user that visits %{host} and creates an account must be explicitly approved by an administrator before they can sign in. Only effective if sign-ups are enabled.', ), { host: this.host, @@ -197,32 +195,34 @@ export default { ), domainAllowListLabel: s__('ApplicationSettings|Allowed domains for sign-ups'), domainAllowListDescription: s__( - 'ApplicationSettings|ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com', + 'ApplicationSettings|Only users with e-mail addresses that match these domain(s) can sign up. Wildcards allowed. Use separate lines for multiple entries. Example: domain.com, *.domain.com', ), userCapLabel: s__('ApplicationSettings|User cap'), userCapDescription: s__( - 'ApplicationSettings|Once the instance reaches the user cap, any user who is added or requests access will have to be approved by an admin. Leave the field empty for unlimited.', + 'ApplicationSettings|After the instance reaches the user cap, any user who is added or requests access must be approved by an administrator. Leave blank for unlimited.', ), domainDenyListGroupLabel: s__('ApplicationSettings|Domain denylist'), - domainDenyListLabel: s__('ApplicationSettings|Enable domain denylist for sign ups'), + domainDenyListLabel: s__('ApplicationSettings|Enable domain denylist for sign-ups'), domainDenyListTypeFileLabel: s__('ApplicationSettings|Upload denylist file'), domainDenyListTypeRawLabel: s__('ApplicationSettings|Enter denylist manually'), domainDenyListFileLabel: s__('ApplicationSettings|Denylist file'), domainDenyListFileDescription: s__( - 'ApplicationSettings|Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines or commas for multiple entries.', + 'ApplicationSettings|Users with e-mail addresses that match these domain(s) cannot sign up. Wildcards allowed. Use separate lines or commas for multiple entries.', ), domainDenyListListLabel: s__('ApplicationSettings|Denied domains for sign-ups'), domainDenyListListDescription: s__( - 'ApplicationSettings|Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com', + 'ApplicationSettings|Users with e-mail addresses that match these domain(s) cannot sign up. Wildcards allowed. Use separate lines for multiple entries. Example: domain.com, *.domain.com', ), domainPlaceholder: s__('ApplicationSettings|domain.com'), emailRestrictionsEnabledGroupLabel: s__('ApplicationSettings|Email restrictions'), emailRestrictionsEnabledLabel: s__( - 'ApplicationSettings|Enable email restrictions for sign ups', + 'ApplicationSettings|Enable email restrictions for sign-ups', ), emailRestrictionsGroupLabel: s__('ApplicationSettings|Email restrictions for sign-ups'), - afterSignUpTextGroupLabel: s__('ApplicationSettings|After sign up text'), - afterSignUpTextGroupDescription: s__('ApplicationSettings|Markdown enabled'), + afterSignUpTextGroupLabel: s__('ApplicationSettings|After sign-up text'), + afterSignUpTextGroupDescription: s__( + 'ApplicationSettings|Text shown after a user signs up. Markdown enabled.', + ), }, }; </script> @@ -288,19 +288,21 @@ export default { name="application_setting[minimum_password_length]" /> - <gl-sprintf - :message=" - s__( - 'ApplicationSettings|See GitLab\'s %{linkStart}Password Policy Guidelines%{linkEnd}', - ) - " - > - <template #link="{ content }"> - <gl-link :href="form.minimumPasswordLengthHelpLink" target="_blank">{{ - content - }}</gl-link> - </template> - </gl-sprintf> + <template #description> + <gl-sprintf + :message=" + s__( + 'ApplicationSettings|See GitLab\'s %{linkStart}Password Policy Guidelines%{linkEnd}.', + ) + " + > + <template #link="{ content }"> + <gl-link :href="form.minimumPasswordLengthHelpLink" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </template> </gl-form-group> <gl-form-group @@ -380,17 +382,19 @@ export default { name="application_setting[email_restrictions]" ></textarea> - <gl-sprintf - :message=" - s__( - 'ApplicationSettings|Restricts sign-ups for email addresses that match the given regex. See the %{linkStart}supported syntax%{linkEnd} for more information.', - ) - " - > - <template #link="{ content }"> - <gl-link :href="form.supportedSyntaxLinkUrl" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> + <template #description> + <gl-sprintf + :message=" + s__( + 'ApplicationSettings|Restricts sign-ups for email addresses that match the given regex. %{linkStart}What is the supported syntax?%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="form.supportedSyntaxLinkUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> </gl-form-group> <gl-form-group diff --git a/app/assets/javascripts/pages/admin/background_migrations/index.js b/app/assets/javascripts/pages/admin/background_migrations/index.js new file mode 100644 index 00000000000..4c59613140b --- /dev/null +++ b/app/assets/javascripts/pages/admin/background_migrations/index.js @@ -0,0 +1,3 @@ +import { initBackgroundMigrationsApp } from '~/admin/background_migrations'; + +initBackgroundMigrationsApp(); diff --git a/app/assets/javascripts/pages/admin/clusters/index.js b/app/assets/javascripts/pages/admin/clusters/index.js deleted file mode 100644 index f398b1cee82..00000000000 --- a/app/assets/javascripts/pages/admin/clusters/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initCreateCluster from '~/create_cluster/init_create_cluster'; - -initCreateCluster(document, gon); diff --git a/app/assets/javascripts/pages/groups/clusters/index.js b/app/assets/javascripts/pages/groups/clusters/index.js index 4d48bd4be2b..99fb7fa68a9 100644 --- a/app/assets/javascripts/pages/groups/clusters/index.js +++ b/app/assets/javascripts/pages/groups/clusters/index.js @@ -1,5 +1,3 @@ import initIntegrationForm from '~/clusters/forms/show/index'; -import initCreateCluster from '~/create_cluster/init_create_cluster'; -initCreateCluster(document, gon); initIntegrationForm(); diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue index c3ac074cd7a..713287f65b4 100644 --- a/app/assets/javascripts/pages/groups/new/components/app.vue +++ b/app/assets/javascripts/pages/groups/new/components/app.vue @@ -21,9 +21,7 @@ const PANELS = [ name: 'import-group-pane', selector: '#import-group-pane', title: s__('GroupsNew|Import group'), - description: s__( - 'GroupsNew|Export groups with all their related data and move to a new GitLab instance.', - ), + description: s__('GroupsNew|Import a group and related data from another GitLab instance.'), illustration: importGroupIllustration, details: 'Migrate your existing groups from another instance of GitLab.', }, 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 5d8ee146e62..52add416f38 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -1,24 +1,9 @@ import initVariableList from '~/ci_variable_list'; -import GroupRunnersFilteredSearchTokenKeys from '~/filtered_search/group_runners_filtered_search_token_keys'; import initSharedRunnersForm from '~/group_settings/mount_shared_runners'; -import { FILTERED_SEARCH } from '~/filtered_search/constants'; -import initFilteredSearch from '~/pages/search/init_filtered_search'; -import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deployments'; -import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; import initSettingsPanels from '~/settings_panels'; // Initialize expandable settings panels initSettingsPanels(); -initFilteredSearch({ - page: FILTERED_SEARCH.ADMIN_RUNNERS, - filteredSearchTokenKeys: GroupRunnersFilteredSearchTokenKeys, - anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR, - useDefaultState: false, -}); - initSharedRunnersForm(); initVariableList(); - -initInstallRunner(); -initRunnerAwsDeployments(); diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue index 9a4054eb110..35a8d3d979a 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue +++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue @@ -9,6 +9,7 @@ import { getBulkImportsHistory } from '~/rest_api'; import ImportStatus from '~/import_entities/components/import_status.vue'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { DEFAULT_ERROR } from '../utils/error_messages'; @@ -16,6 +17,8 @@ const DEFAULT_PER_PAGE = 20; const DEFAULT_TH_CLASSES = 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1! gl-p-5!'; +const HISTORY_PAGINATION_SIZE_PERSIST_KEY = 'gl-bulk-imports-history-per-page'; + const tableCell = (config) => ({ thClass: `${DEFAULT_TH_CLASSES}`, tdClass: (value, key, item) => { @@ -37,6 +40,7 @@ export default { PaginationBar, ImportStatus, TimeAgo, + LocalStorageSync, }, data() { @@ -59,7 +63,7 @@ export default { }), tableCell({ key: 'destination_name', - label: s__('BulkImport|New group'), + label: s__('BulkImport|Destination group'), thClass: `${DEFAULT_TH_CLASSES} gl-w-40p`, }), tableCell({ @@ -85,10 +89,13 @@ export default { this.loadHistoryItems(); }, deep: true, - immediate: true, }, }, + mounted() { + this.loadHistoryItems(); + }, + methods: { async loadHistoryItems() { try { @@ -116,6 +123,7 @@ export default { }, gitlabLogo: window.gon.gitlab_logo, + historyPaginationSizePersistKey: HISTORY_PAGINATION_SIZE_PERSIST_KEY, }; </script> @@ -171,5 +179,9 @@ export default { @set-page-size="paginationConfig.perPage = $event" /> </template> + <local-storage-sync + v-model="paginationConfig.perPage" + :storage-key="$options.historyPaginationSizePersistKey" + /> </div> </template> diff --git a/app/assets/javascripts/pages/projects/clusters/index.js b/app/assets/javascripts/pages/projects/clusters/index.js deleted file mode 100644 index f398b1cee82..00000000000 --- a/app/assets/javascripts/pages/projects/clusters/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initCreateCluster from '~/create_cluster/init_create_cluster'; - -initCreateCluster(document, gon); diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js index 71ab5a0b19c..0b34f374abc 100644 --- a/app/assets/javascripts/pages/projects/clusters/show/index.js +++ b/app/assets/javascripts/pages/projects/clusters/show/index.js @@ -1,6 +1,6 @@ import ClustersBundle from '~/clusters/clusters_bundle'; import initIntegrationForm from '~/clusters/forms/show'; -import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; +import initGkeNamespace from '~/clusters/gke_cluster_namespace'; import initClusterHealth from './cluster_health'; new ClustersBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue index 1667f2c3576..1912477758b 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue @@ -1,5 +1,6 @@ <script> -import { GlLink, GlIcon, GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { GlLink, GlIcon, GlButton, GlPopover, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import { isExperimentVariant } from '~/experimentation/utils'; import eventHub from '~/invite_members/event_hub'; @@ -12,6 +13,7 @@ export default { GlLink, GlIcon, GlButton, + GlPopover, GitlabExperiment, }, directives: { @@ -19,6 +21,8 @@ export default { }, i18n: { trialOnly: s__('LearnGitlab|Trial only'), + contactAdmin: s__('LearnGitlab|Contact your administrator to start a free Ultimate trial.'), + viewAdminList: s__('LearnGitlab|View administrator list'), watchHow: __('Watch how'), }, props: { @@ -31,6 +35,11 @@ export default { type: Object, }, }, + data() { + return { + popoverId: uniqueId('contact-admin-'), + }; + }, computed: { linkTitle() { return ACTION_LABELS[this.action].title; @@ -78,7 +87,7 @@ export default { {{ linkTitle }} </gl-link> <gl-link - v-else + v-else-if="value.enabled" :target="openInNewTab ? '_blank' : '_self'" :href="value.url" data-testid="uncompleted-learn-gitlab-link" @@ -87,6 +96,33 @@ export default { > {{ linkTitle }} </gl-link> + <template v-else> + <div data-testid="disabled-learn-gitlab-link">{{ linkTitle }}</div> + <gl-button + :id="popoverId" + category="tertiary" + icon="question-o" + class="ml-auto" + :aria-label="$options.i18n.contactAdmin" + size="small" + data-testid="contact-admin-popover-trigger" + /> + <gl-popover + :target="popoverId" + placement="top" + triggers="hover focus" + data-testid="contact-admin-popover" + > + <p>{{ $options.i18n.contactAdmin }}</p> + <gl-link + :href="value.url" + class="font-size-inherit" + data-testid="view-administrator-link-text" + > + {{ $options.i18n.viewAdminList }} + </gl-link> + </gl-popover> + </template> <gitlab-experiment name="video_tutorials_continuous_onboarding"> <template #control></template> <template #candidate> @@ -100,6 +136,7 @@ export default { :href="linkToVideoTutorial" target="_blank" class="ml-auto" + size="small" data-testid="video-tutorial-link" data-track-action="click_video_link" :data-track-label="linkTitle" diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js index 9ba5e17237a..05bacd9b350 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js @@ -30,6 +30,7 @@ export const ACTION_LABELS = { description: s__('LearnGitLab|Try all GitLab features for 30 days, no credit card required.'), section: 'workspace', position: 3, + openInNewTab: true, }, codeOwnersEnabled: { title: s__('LearnGitLab|Add code owners'), @@ -40,6 +41,7 @@ export const ACTION_LABELS = { trialRequired: true, section: 'workspace', position: 4, + openInNewTab: true, videoTutorial: 'https://vimeo.com/670896787', }, requiredMrApprovalsEnabled: { @@ -49,6 +51,7 @@ export const ACTION_LABELS = { trialRequired: true, section: 'workspace', position: 5, + openInNewTab: true, videoTutorial: 'https://vimeo.com/670904904', }, mergeRequestCreated: { diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index 0e0c1475eda..48e360ce762 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -8,6 +8,7 @@ import createDefaultClient from '~/lib/graphql'; import initSourcegraph from '~/sourcegraph'; import ZenMode from '~/zen_mode'; import initAwardsApp from '~/emoji/awards_app'; +import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue'; import getStateQuery from './queries/get_state.query.graphql'; export default function initMergeRequestShow() { @@ -19,22 +20,45 @@ export default function initMergeRequestShow() { initAwardsApp(document.getElementById('js-vue-awards-block')); const el = document.querySelector('.js-mr-status-box'); + const { iid, issuableType, projectPath } = el.dataset; const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); // eslint-disable-next-line no-new new Vue({ el, + name: 'IssuableStatusBoxRoot', apolloProvider, provide: { query: getStateQuery, - projectPath: el.dataset.projectPath, - iid: el.dataset.iid, + iid, + projectPath, }, render(h) { return h(StatusBox, { props: { initialState: el.dataset.state, + issuableType, + }, + }); + }, + }); + + const modalEl = document.getElementById('js-check-out-modal'); + + // eslint-disable-next-line no-new + new Vue({ + el: modalEl, + render(h) { + return h(MrWidgetHowToMergeModal, { + props: { + canMerge: modalEl.dataset.canMerge === 'true', + isFork: modalEl.dataset.isFork === 'true', + sourceBranch: modalEl.dataset.sourceBranch, + sourceProjectPath: modalEl.dataset.sourceProjectPath, + targetBranch: modalEl.dataset.targetBranch, + sourceProjectDefaultUrl: modalEl.dataset.sourceProjectDefaultUrl, + reviewingDocsPath: modalEl.dataset.reviewingDocsPath, }, }); }, diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 4f57e1308df..032e2410233 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -65,6 +65,7 @@ export default class Project { const fieldName = $dropdown.data('fieldName'); const shouldVisit = Boolean($dropdown.data('visit')); const $form = $dropdown.closest('form'); + const path = $form.find('#path').val(); const action = $form.attr('action'); const linkTarget = mergeUrlParams(serializeForm($form[0]), action); @@ -116,20 +117,21 @@ export default class Project { }, clicked(options) { const { e } = options; - e.preventDefault(); - // Since this page does not reload when changing directories in a repo - // the rendered links do not have the path to the current directory. - // This updates the path based on the current url and then opens - // the the url with the updated path parameter. - if (shouldVisit) { + if (!shouldVisit) { + e.preventDefault(); + } + + // Some pages need to dynamically get the current path + // so they can opt-in to JS getting the path from the + // current URL by not setting a path in the dropdown form + if (shouldVisit && path === undefined) { + e.preventDefault(); + const selectedUrl = new URL(e.target.href); const loc = window.location.href; if (loc.includes('/-/')) { - // Since the current ref in renderRow is outdated on page changes - // (To be addressed in: https://gitlab.com/gitlab-org/gitlab/-/issues/327085) - // We are deciphering the current ref from the dropdown data instead const currentRef = $dropdown.data('ref'); // The split and startWith is to ensure an exact word match // and avoid partial match ie. currentRef is "dev" and loc is "development" diff --git a/app/assets/javascripts/pages/projects/serverless/index.js b/app/assets/javascripts/pages/projects/serverless/index.js deleted file mode 100644 index 9ae81b327b1..00000000000 --- a/app/assets/javascripts/pages/projects/serverless/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import ServerlessBundle from '~/serverless/serverless_bundle'; - -new ServerlessBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/services/edit/index.js b/app/assets/javascripts/pages/projects/services/edit/index.js index 2048d3dfc37..64df0d07d74 100644 --- a/app/assets/javascripts/pages/projects/services/edit/index.js +++ b/app/assets/javascripts/pages/projects/services/edit/index.js @@ -1,5 +1,4 @@ import initIntegrationSettingsForm from '~/integrations/edit'; -import PrometheusAlerts from '~/prometheus_alerts'; import CustomMetrics from '~/prometheus_metrics/custom_metrics'; initIntegrationSettingsForm(); @@ -10,5 +9,3 @@ if (prometheusSettingsWrapper) { const customMetrics = new CustomMetrics(prometheusSettingsSelector); customMetrics.init(); } - -PrometheusAlerts(); diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 71c6773c176..e2b1a702560 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -1,8 +1,5 @@ -import initTree from 'ee_else_ce/repository'; -import Activities from '~/activities'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import { BlobViewer } from '~/blob/viewer'; -import { initUploadForm } from '~/blob_edit/blob_bundle'; + import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import leaveByUrl from '~/namespaces/leave_by_url'; @@ -10,33 +7,38 @@ import initVueNotificationsDropdown from '~/notifications'; import Star from '~/projects/star'; import { initUploadFileTrigger } from '~/projects/upload_file'; import initReadMore from '~/read_more'; -import UserCallout from '~/user_callout'; - -initReadMore(); -new Star(); // eslint-disable-line no-new - -// eslint-disable-next-line no-new -new UserCallout({ - setCalloutPerProject: false, - className: 'js-autodevops-banner', -}); // Project show page loads different overview content based on user preferences - if (document.querySelector('.js-upload-blob-form')) { - initUploadForm(); + import(/* webpackChunkName: 'blobBundle' */ '~/blob_edit/blob_bundle') + .then(({ initUploadForm }) => { + initUploadForm(); + }) + .catch(() => {}); } if (document.getElementById('js-tree-list')) { - initTree(); + import(/* webpackChunkName: 'treeList' */ 'ee_else_ce/repository') + .then(({ default: initTree }) => { + initTree(); + }) + .catch(() => {}); } if (document.querySelector('.blob-viewer')) { - new BlobViewer(); // eslint-disable-line no-new + import(/* webpackChunkName: 'blobViewer' */ '~/blob/viewer') + .then(({ BlobViewer }) => { + new BlobViewer(); // eslint-disable-line no-new + }) + .catch(() => {}); } if (document.querySelector('.project-show-activity')) { - new Activities(); // eslint-disable-line no-new + import(/* webpackChunkName: 'activitiesList' */ '~/activities') + .then(({ default: Activities }) => { + new Activities(); // eslint-disable-line no-new + }) + .catch(() => {}); } leaveByUrl('project'); @@ -48,3 +50,18 @@ new ShortcutsNavigation(); // eslint-disable-line no-new initUploadFileTrigger(); initInviteMembersModal(); initInviteMembersTrigger(); + +initReadMore(); +new Star(); // eslint-disable-line no-new + +if (document.querySelector('.js-autodevops-banner')) { + import(/* webpackChunkName: 'userCallOut' */ '~/user_callout') + .then(({ default: UserCallout }) => { + // eslint-disable-next-line no-new + new UserCallout({ + setCalloutPerProject: false, + className: 'js-autodevops-banner', + }); + }) + .catch(() => {}); +} diff --git a/app/assets/javascripts/pages/projects/wikis/edit/index.js b/app/assets/javascripts/pages/projects/wikis/edit/index.js deleted file mode 100644 index b2288c2655c..00000000000 --- a/app/assets/javascripts/pages/projects/wikis/edit/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { mountApplications } from '~/pages/shared/wikis/edit'; - -mountApplications(); diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js index 83fcd348ddf..692baee383b 100644 --- a/app/assets/javascripts/pages/projects/wikis/index.js +++ b/app/assets/javascripts/pages/projects/wikis/index.js @@ -1,3 +1,6 @@ import Wikis from '~/pages/shared/wikis/wikis'; +import { mountApplications } from '~/pages/shared/wikis/async_edit'; + +mountApplications(); export default new Wikis(); diff --git a/app/assets/javascripts/pages/projects/wikis/show/index.js b/app/assets/javascripts/pages/projects/wikis/show/index.js index 7ca5f6964cd..288f6b616cc 100644 --- a/app/assets/javascripts/pages/projects/wikis/show/index.js +++ b/app/assets/javascripts/pages/projects/wikis/show/index.js @@ -1,5 +1,3 @@ import { mountApplications } from '~/pages/shared/wikis/show'; -import { mountApplications as mountEditApplications } from '~/pages/shared/wikis/async_edit'; mountApplications(); -mountEditApplications(); diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js index 8bbe81a9ed5..94a5c1cb29b 100644 --- a/app/assets/javascripts/pages/registrations/new/index.js +++ b/app/assets/javascripts/pages/registrations/new/index.js @@ -3,9 +3,14 @@ import { trackNewRegistrations } from '~/google_tag_manager'; import NoEmojiValidator from '~/emoji/no_emoji_validator'; import LengthValidator from '~/pages/sessions/new/length_validator'; import UsernameValidator from '~/pages/sessions/new/username_validator'; +import Tracking from '~/tracking'; new UsernameValidator(); // eslint-disable-line no-new new LengthValidator(); // eslint-disable-line no-new new NoEmojiValidator(); // eslint-disable-line no-new trackNewRegistrations(); + +Tracking.enableFormTracking({ + forms: { allow: ['new_user'] }, +}); diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index dee832c01d5..100ffc0664b 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -13,6 +13,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-approaching-seats-count-threshold', '.js-storage-enforcement-banner', '.js-user-over-limit-free-plan-alert', + '.js-minute-limit-banner', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue index 8536db78dfb..d9da238358f 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -54,15 +54,15 @@ export default { return { message: this.defaultMessage, openMergeRequest: false, - targetBranch: this.currentBranch, + sourceBranch: this.currentBranch, }; }, computed: { isCommitFormFilledOut() { - return this.message && this.targetBranch; + return this.message && this.sourceBranch; }, - isCurrentBranchTarget() { - return this.targetBranch === this.currentBranch; + isCurrentBranchSourceBranch() { + return this.sourceBranch === this.currentBranch; }, isSubmitDisabled() { return !this.isCommitFormFilledOut || (!this.hasUnsavedChanges && !this.isNewCiConfigFile); @@ -79,7 +79,7 @@ export default { onSubmit() { this.$emit('submit', { message: this.message, - targetBranch: this.targetBranch, + sourceBranch: this.sourceBranch, openMergeRequest: this.openMergeRequest, }); }, @@ -93,7 +93,7 @@ export default { }, i18n: { commitMessage: __('Commit message'), - targetBranch: __('Target Branch'), + sourceBranch: __('Branch'), startMergeRequest: __('Start a %{new_merge_request} with these changes'), newMergeRequest: __('new merge request'), commitChanges: __('Commit changes'), @@ -120,20 +120,20 @@ export default { /> </gl-form-group> <gl-form-group - id="target-branch-group" - :label="$options.i18n.targetBranch" + id="source-branch-group" + :label="$options.i18n.sourceBranch" label-cols-sm="2" - label-for="target-branch-field" + label-for="source-branch-field" > <gl-form-input - id="target-branch-field" - v-model="targetBranch" + id="source-branch-field" + v-model="sourceBranch" class="gl-font-monospace!" required - data-qa-selector="target_branch_field" + data-qa-selector="source_branch_field" /> <gl-form-checkbox - v-if="!isCurrentBranchTarget" + v-if="!isCurrentBranchSourceBranch" v-model="openMergeRequest" data-testid="new-mr-checkbox" data-qa-selector="new_mr_checkbox" diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue index 4ef598d6ff3..9cbf60b1c8f 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue @@ -75,7 +75,7 @@ export default { }, }, methods: { - async onCommitSubmit({ message, targetBranch, openMergeRequest }) { + async onCommitSubmit({ message, sourceBranch, openMergeRequest }) { this.isSaving = true; try { @@ -88,7 +88,7 @@ export default { variables: { action: this.action, projectPath: this.projectFullPath, - branch: targetBranch, + branch: sourceBranch, startBranch: this.currentBranch, message, filePath: this.ciConfigPath, @@ -104,12 +104,11 @@ export default { if (errors?.length) { this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors }); } else { - const commitBranch = targetBranch; const params = openMergeRequest ? { type: COMMIT_SUCCESS_WITH_REDIRECT, params: { - sourceBranch: commitBranch, + sourceBranch, targetBranch: this.currentBranch, }, } @@ -119,10 +118,10 @@ export default { ...params, }); - this.updateLastCommitBranch(targetBranch); - this.updateCurrentBranch(targetBranch); + this.updateLastCommitBranch(sourceBranch); + this.updateCurrentBranch(sourceBranch); - if (this.currentBranch === targetBranch) { + if (this.currentBranch === sourceBranch) { this.$emit('updateCommitSha'); } } diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue index 853e839a7ab..42e2d34fa3a 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue @@ -46,7 +46,9 @@ export default { :value="mergedYaml" :file-name="ciConfigPath" :file-global-id="fileGlobalId" - :editor-options="{ readOnly: true }" + :editor-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + readOnly: true, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" v-on="$listeners" /> </div> diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue index 83b074dd55c..58df98d0fb7 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -1,26 +1,74 @@ <script> +import { GlButton } from '@gitlab/ui'; +import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_LOADING } from '../../constants'; +import FileTreePopover from '../popovers/file_tree_popover.vue'; import BranchSwitcher from './branch_switcher.vue'; export default { components: { BranchSwitcher, + FileTreePopover, + GlButton, }, + mixins: [glFeatureFlagMixin()], props: { hasUnsavedChanges: { type: Boolean, required: false, default: false, }, + isNewCiConfigFile: { + type: Boolean, + required: false, + default: false, + }, shouldLoadNewBranch: { type: Boolean, required: false, default: false, }, }, + apollo: { + appStatus: { + query: getAppStatus, + update(data) { + return data.app.status; + }, + }, + }, + computed: { + isAppLoading() { + return this.appStatus === EDITOR_APP_STATUS_LOADING; + }, + showFileTreeToggle() { + return ( + this.glFeatures.pipelineEditorFileTree && + !this.isNewCiConfigFile && + this.appStatus !== EDITOR_APP_STATUS_EMPTY + ); + }, + }, + methods: { + onFileTreeBtnClick() { + this.$emit('toggle-file-tree'); + }, + }, }; </script> <template> <div class="gl-mb-4"> + <gl-button + v-if="showFileTreeToggle" + id="file-tree-toggle" + icon="file-tree" + data-testid="file-tree-toggle" + :aria-label="__('File Tree')" + :loading="isAppLoading" + @click="onFileTreeBtnClick" + /> + <file-tree-popover v-if="showFileTreeToggle" /> <branch-switcher :has-unsaved-changes="hasUnsavedChanges" :should-load-new-branch="shouldLoadNewBranch" diff --git a/app/assets/javascripts/pipeline_editor/components/file_tree/container.vue b/app/assets/javascripts/pipeline_editor/components/file_tree/container.vue new file mode 100644 index 00000000000..280cd729a43 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/file_tree/container.vue @@ -0,0 +1,78 @@ +<script> +import { GlAlert, GlTooltipDirective } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import { FILE_TREE_TIP_DISMISSED_KEY } from '../../constants'; +import FileItem from './file_item.vue'; + +const i18n = { + tipBtn: __('Learn more'), + tipDescription: s__( + 'PipelineEditorFileTree|When you use the include keyword to add pipeline configuration from files in the project, those files will be listed here.', + ), + tipTitle: s__('PipelineEditorFileTree|Configuration files added with the include keyword'), +}; + +export default { + i18n, + name: 'PipelineEditorFileTreeContainer', + components: { + FileIcon, + FileItem, + GlAlert, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['ciConfigPath', 'includesHelpPagePath'], + props: { + includes: { + type: Array, + required: true, + }, + }, + data() { + return { + canShowTip: localStorage.getItem(FILE_TREE_TIP_DISMISSED_KEY) !== 'true', + }; + }, + computed: { + showTip() { + return this.includes.length === 0 && this.canShowTip; + }, + }, + methods: { + dismissTip() { + this.canShowTip = false; + localStorage.setItem(FILE_TREE_TIP_DISMISSED_KEY, 'true'); + }, + }, +}; +</script> +<template> + <aside class="file-tree-container gl-mr-5 gl-mb-5"> + <div + v-gl-tooltip + :title="ciConfigPath" + class="gl-bg-gray-50 gl-py-2 gl-px-3 gl-mb-3 gl-rounded-base" + > + <span class="file-row-name gl-str-truncated" :title="ciConfigPath"> + <file-icon class="file-row-icon" :file-name="ciConfigPath" /> + <span data-testid="current-config-filename">{{ ciConfigPath }}</span> + </span> + </div> + <gl-alert + v-if="showTip" + variant="tip" + :title="$options.i18n.tipTitle" + :secondary-button-text="$options.i18n.tipBtn" + :secondary-button-link="includesHelpPagePath" + @dismiss="dismissTip" + > + {{ $options.i18n.tipDescription }} + </gl-alert> + <div class="gl-overflow-y-auto"> + <file-item v-for="file in includes" :key="file.location" :file="file" /> + </div> + </aside> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/file_tree/file_item.vue b/app/assets/javascripts/pipeline_editor/components/file_tree/file_item.vue new file mode 100644 index 00000000000..786d483b5b9 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/file_tree/file_item.vue @@ -0,0 +1,45 @@ +<script> +import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; + +export default { + name: 'PipelineEditorFileItem', + components: { + FileIcon, + GlIcon, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + fileName() { + return this.file.location; + }, + filePath() { + return this.file.blob || this.file.raw; + }, + }, +}; +</script> +<template> + <gl-link + v-gl-tooltip + :href="filePath" + :title="fileName" + target="_blank" + class="file-tree-includes-link gl-display-flex gl-justify-content-space-between gl-hover-bg-gray-50 gl-text-body gl-hover-text-gray-900 gl-hover-text-decoration-none gl-py-2 gl-px-3 gl-rounded-base" + > + <span class="file-row-name gl-str-truncated" :title="fileName"> + <file-icon class="file-row-icon" :file-name="fileName" /> + <span>{{ fileName }}</span> + </span> + <gl-icon class="gl-display-none gl-relative gl-text-gray-500" name="external-link" /> + </gl-link> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue index 25a78aab933..7beabcfe403 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue @@ -47,6 +47,12 @@ export default { downstreamPipelines() { return this.linkedPipelines?.downstream?.nodes || []; }, + hasDownstreamPipelines() { + return this.downstreamPipelines.length > 0; + }, + hasPipelineStages() { + return this.pipelineStages.length > 0; + }, pipelinePath() { return this.pipeline.detailedStatus?.detailsPath || ''; }, @@ -73,9 +79,6 @@ export default { }; }); }, - showDownstreamPipelines() { - return this.downstreamPipelines.length > 0; - }, upstreamPipeline() { return this.linkedPipelines?.upstream; }, @@ -84,15 +87,20 @@ export default { </script> <template> - <div v-if="pipelineStages.length > 0" class="stage-cell gl-mr-5"> + <div + v-if="hasPipelineStages" + class="gl-align-items-center gl-display-inline-flex gl-flex-wrap stage-cell gl-mr-5" + > <linked-pipelines-mini-list v-if="upstreamPipeline" - :triggered-by="[upstreamPipeline]" + :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ + upstreamPipeline, + ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" data-testid="pipeline-editor-mini-graph-upstream" /> - <pipeline-mini-graph class="gl-display-inline" :stages="pipelineStages" /> + <pipeline-mini-graph :stages="pipelineStages" /> <linked-pipelines-mini-list - v-if="showDownstreamPipelines" + v-if="hasDownstreamPipelines" :triggered="downstreamPipelines" :pipeline-path="pipelinePath" data-testid="pipeline-editor-mini-graph-downstream" diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index d50e6f9a623..da31fc62d09 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -23,7 +23,7 @@ import CiEditorHeader from './editor/ci_editor_header.vue'; import TextEditor from './editor/text_editor.vue'; import CiLint from './lint/ci_lint.vue'; import EditorTab from './ui/editor_tab.vue'; -import WalkthroughPopover from './walkthrough_popover.vue'; +import WalkthroughPopover from './popovers/walkthrough_popover.vue'; export default { i18n: { diff --git a/app/assets/javascripts/pipeline_editor/components/popovers/file_tree_popover.vue b/app/assets/javascripts/pipeline_editor/components/popovers/file_tree_popover.vue new file mode 100644 index 00000000000..6270429535d --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/popovers/file_tree_popover.vue @@ -0,0 +1,59 @@ +<script> +import { GlLink, GlPopover, GlOutsideDirective as Outside, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { FILE_TREE_POPOVER_DISMISSED_KEY } from '../../constants'; + +export default { + name: 'PipelineEditorFileTreePopover', + directives: { Outside }, + i18n: { + description: s__( + 'pipelineEditorWalkthrough|You can use the file tree to view your pipeline configuration files. %{linkStart}Learn more%{linkEnd}', + ), + }, + components: { + GlLink, + GlPopover, + GlSprintf, + }, + inject: ['includesHelpPagePath'], + data() { + return { + showPopover: false, + }; + }, + mounted() { + this.showPopover = localStorage.getItem(FILE_TREE_POPOVER_DISMISSED_KEY) !== 'true'; + }, + methods: { + closePopover() { + this.showPopover = false; + }, + dismissPermanently() { + this.closePopover(); + localStorage.setItem(FILE_TREE_POPOVER_DISMISSED_KEY, 'true'); + }, + }, +}; +</script> + +<template> + <gl-popover + v-if="showPopover" + show + show-close-button + target="file-tree-toggle" + triggers="manual" + placement="right" + data-qa-selector="file_tree_popover" + @close-button-clicked="dismissPermanently" + > + <div v-outside="closePopover" class="gl-font-base gl-mb-3"> + <gl-sprintf :message="$options.i18n.description"> + <template #link="{ content }"> + <gl-link :href="includesHelpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + </gl-popover> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/walkthrough_popover.vue b/app/assets/javascripts/pipeline_editor/components/popovers/walkthrough_popover.vue index 5742b11b841..c636d8b8e34 100644 --- a/app/assets/javascripts/pipeline_editor/components/walkthrough_popover.vue +++ b/app/assets/javascripts/pipeline_editor/components/popovers/walkthrough_popover.vue @@ -76,7 +76,7 @@ export default { @click="handleClickCta" > <gl-emoji data-name="rocket" /> - {{ this.$options.i18n.ctaText }} + {{ $options.i18n.ctaText }} </gl-button> </div> </gl-popover> diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index 9b4732b26d2..ff7c742f588 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -49,6 +49,10 @@ export const BRANCH_PAGINATION_LIMIT = 20; export const BRANCH_SEARCH_DEBOUNCE = '500'; export const SOURCE_EDITOR_DEBOUNCE = 500; +export const FILE_TREE_DISPLAY_KEY = 'pipeline_editor_file_tree_display'; +export const FILE_TREE_POPOVER_DISMISSED_KEY = 'pipeline_editor_file_tree_popover_dismissed'; +export const FILE_TREE_TIP_DISMISSED_KEY = 'pipeline_editor_file_tree_tip_dismissed'; + export const STARTER_TEMPLATE_NAME = 'Getting-Started'; export const pipelineEditorTrackingOptions = { diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql index df7de6a1f54..5354ed7c2d5 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql @@ -3,6 +3,12 @@ query getCiConfigData($projectPath: ID!, $sha: String, $content: String!) { ciConfig(projectPath: $projectPath, sha: $sha, content: $content) { errors + includes { + location + type + blob + raw + } mergedYaml status stages { diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 732fc665c9e..e13d9cf9df0 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -30,6 +30,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { defaultBranch, emptyStateIllustrationPath, helpPaths, + includesHelpPagePath, lintHelpPagePath, lintUnavailableHelpPagePath, needsHelpPagePath, @@ -41,7 +42,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { runnerHelpPagePath, totalBranches, ymlHelpPagePath, - } = el?.dataset; + } = el.dataset; const configurationPaths = Object.fromEntries( Object.entries(CODE_SNIPPET_SOURCE_SETTINGS).map(([source, { datasetKey }]) => [ @@ -118,6 +119,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { defaultBranch, emptyStateIllustrationPath, helpPaths, + includesHelpPagePath, lintHelpPagePath, lintUnavailableHelpPagePath, needsHelpPagePath, diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 4e6a4ffa6d2..3fd31edec2c 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -104,6 +104,7 @@ export default { this.setAppStatus(EDITOR_APP_STATUS_EMPTY); } + this.isNewCiConfigFile = false; if (!hasCIFile) { if (this.shouldSkipStartScreen) { this.setNewEmptyCiConfigFile(); @@ -381,7 +382,7 @@ export default { </script> <template> - <div class="gl-mt-4 gl-relative"> + <div class="gl-mt-4 gl-relative" data-qa-selector="pipeline_editor_app"> <gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" /> <pipeline-editor-empty-state v-else-if="showStartScreen" diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index 23e3ce10d5a..59022a91322 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -1,12 +1,14 @@ <script> import { GlModal } from '@gitlab/ui'; import { __ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import CommitSection from './components/commit/commit_section.vue'; import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue'; import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue'; +import PipelineEditorFileTree from './components/file_tree/container.vue'; import PipelineEditorHeader from './components/header/pipeline_editor_header.vue'; import PipelineEditorTabs from './components/pipeline_editor_tabs.vue'; -import { CREATE_TAB } from './constants'; +import { CREATE_TAB, FILE_TREE_DISPLAY_KEY } from './constants'; export default { commitSectionRef: 'commitSectionRef', @@ -28,9 +30,11 @@ export default { GlModal, PipelineEditorDrawer, PipelineEditorFileNav, + PipelineEditorFileTree, PipelineEditorHeader, PipelineEditorTabs, }, + mixins: [glFeatureFlagMixin()], props: { ciConfigData: { type: Object, @@ -61,6 +65,7 @@ export default { scrollToCommitForm: false, shouldLoadNewBranch: false, showDrawer: false, + showFileTree: false, showSwitchBranchModal: false, }; }, @@ -68,6 +73,15 @@ export default { showCommitForm() { return this.currentTab === CREATE_TAB; }, + includesFiles() { + return this.ciConfigData?.includes || []; + }, + isFileTreeVisible() { + return this.showFileTree && this.glFeatures.pipelineEditorFileTree; + }, + }, + mounted() { + this.showFileTree = JSON.parse(localStorage.getItem(FILE_TREE_DISPLAY_KEY)) || false; }, methods: { closeBranchModal() { @@ -82,6 +96,10 @@ export default { openDrawer() { this.showDrawer = true; }, + toggleFileTree() { + this.showFileTree = !this.showFileTree; + localStorage.setItem(FILE_TREE_DISPLAY_KEY, this.showFileTree); + }, switchBranch() { this.showSwitchBranchModal = false; this.shouldLoadNewBranch = true; @@ -114,28 +132,39 @@ export default { </gl-modal> <pipeline-editor-file-nav :has-unsaved-changes="hasUnsavedChanges" + :is-new-ci-config-file="isNewCiConfigFile" :should-load-new-branch="shouldLoadNewBranch" @select-branch="handleConfirmSwitchBranch" + @toggle-file-tree="toggleFileTree" v-on="$listeners" /> - <pipeline-editor-header - :ci-config-data="ciConfigData" - :commit-sha="commitSha" - :is-new-ci-config-file="isNewCiConfigFile" - v-on="$listeners" - /> - <pipeline-editor-tabs - :ci-config-data="ciConfigData" - :ci-file-content="ciFileContent" - :commit-sha="commitSha" - :is-new-ci-config-file="isNewCiConfigFile" - :show-drawer="showDrawer" - v-on="$listeners" - @open-drawer="openDrawer" - @close-drawer="closeDrawer" - @set-current-tab="setCurrentTab" - @walkthrough-popover-cta-clicked="setScrollToCommitForm" - /> + <div class="gl-display-flex gl-w-full gl-sm-flex-direction-column"> + <pipeline-editor-file-tree + v-if="isFileTreeVisible" + class="gl-flex-shrink-0" + :includes="includesFiles" + /> + <div class="gl-flex-grow-1 gl-min-w-0"> + <pipeline-editor-header + :ci-config-data="ciConfigData" + :commit-sha="commitSha" + :is-new-ci-config-file="isNewCiConfigFile" + v-on="$listeners" + /> + <pipeline-editor-tabs + :ci-config-data="ciConfigData" + :ci-file-content="ciFileContent" + :commit-sha="commitSha" + :is-new-ci-config-file="isNewCiConfigFile" + :show-drawer="showDrawer" + v-on="$listeners" + @open-drawer="openDrawer" + @close-drawer="closeDrawer" + @set-current-tab="setCurrentTab" + @walkthrough-popover-cta-clicked="setScrollToCommitForm" + /> + </div> + </div> <commit-section v-if="showCommitForm" :ref="$options.commitSectionRef" 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 32e1e18b684..d84fc724d38 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -313,7 +313,7 @@ export default { errors = [], warnings = [], total_warnings: totalWarnings = 0, - } = err?.response?.data; + } = err.response.data; const [error] = errors; this.reportError({ diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js index a645ea8603b..927eeb5e144 100644 --- a/app/assets/javascripts/pipeline_new/index.js +++ b/app/assets/javascripts/pipeline_new/index.js @@ -17,7 +17,7 @@ export default () => { fileParam, settingsLink, maxWarnings, - } = el?.dataset; + } = el.dataset; const variableParams = JSON.parse(varParam); const fileParams = JSON.parse(fileParam); diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index 16fb931ec2b..475dd3bf36e 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -238,7 +238,7 @@ export default { </div> </template> <template v-if="dagDocPath" #actions> - <gl-button :href="dagDocPath" target="__blank" variant="success"> + <gl-button :href="dagDocPath" target="_blank" variant="confirm"> {{ $options.emptyStateTexts.button }} </gl-button> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js index 0b59612b25c..85ca52f633e 100644 --- a/app/assets/javascripts/pipelines/components/graph/constants.js +++ b/app/assets/javascripts/pipelines/components/graph/constants.js @@ -15,4 +15,8 @@ export const VIEW_TYPE_KEY = 'pipeline_graph_view_type'; export const SINGLE_JOB = 'single_job'; export const JOB_DROPDOWN = 'job_dropdown'; +export const BUILD_KIND = 'BUILD'; +export const BRIDGE_KIND = 'BRIDGE'; + +export const ACTION_FAILURE = 'action_failure'; export const IID_FAILURE = 'missing_iid'; diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 015f0519c72..31a34ab4fb5 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -233,6 +233,7 @@ export default { :view-type="viewType" @downstreamHovered="setSourceJob" @pipelineExpandToggle="togglePipelineExpanded" + @refreshPipelineGraph="$emit('refreshPipelineGraph')" @scrollContainer="slidePipelineContainer" @error="onError" /> 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 534ad25a35d..f822e2c0874 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -8,7 +8,7 @@ import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; import getPipelineQuery from '../../graphql/queries/get_pipeline_header_data.query.graphql'; import { reportToSentry, reportMessageToSentry } from '../../utils'; -import { IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants'; +import { ACTION_FAILURE, IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants'; import PipelineGraph from './graph_component.vue'; import GraphViewSelector from './graph_view_selector.vue'; import { @@ -57,13 +57,29 @@ export default { showLinks: false, }; }, - errorTexts: { - [DRAW_FAILURE]: __('An error occurred while drawing job relationship links.'), - [IID_FAILURE]: __( - 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.', - ), - [LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'), - [DEFAULT]: __('An unknown error occurred while loading this graph.'), + errors: { + [ACTION_FAILURE]: { + text: __('An error occurred while performing this action.'), + variant: 'danger', + }, + [DRAW_FAILURE]: { + text: __('An error occurred while drawing job relationship links.'), + variant: 'danger', + }, + [IID_FAILURE]: { + text: __( + 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.', + ), + variant: 'info', + }, + [LOAD_FAILURE]: { + text: __('Currently unable to fetch data for this pipeline.'), + variant: 'danger', + }, + [DEFAULT]: { + text: __('An unknown error occurred while loading this graph.'), + variant: 'danger', + }, }, apollo: { callouts: { @@ -154,28 +170,12 @@ export default { }, computed: { alert() { - switch (this.alertType) { - case DRAW_FAILURE: - return { - text: this.$options.errorTexts[DRAW_FAILURE], - variant: 'danger', - }; - case IID_FAILURE: - return { - text: this.$options.errorTexts[IID_FAILURE], - variant: 'info', - }; - case LOAD_FAILURE: - return { - text: this.$options.errorTexts[LOAD_FAILURE], - variant: 'danger', - }; - default: - return { - text: this.$options.errorTexts[DEFAULT], - variant: 'danger', - }; - } + const { errors } = this.$options; + + return { + text: errors[this.alertType]?.text ?? errors[DEFAULT].text, + variant: errors[this.alertType]?.variant ?? errors[DEFAULT].variant, + }; }, configPaths() { return { diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index f69b25dfa7c..362571930d6 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { GlBadge, GlLink, GlTooltipDirective } from '@gitlab/ui'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { sprintf, __ } from '~/locale'; @@ -7,7 +7,7 @@ import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { reportToSentry } from '../../utils'; import ActionComponent from '../jobs_shared/action_component.vue'; import JobNameComponent from '../jobs_shared/job_name_component.vue'; -import { SINGLE_JOB } from './constants'; +import { BRIDGE_KIND, SINGLE_JOB } from './constants'; /** * Renders the badge for the pipeline graph and the job's dropdown. @@ -35,11 +35,16 @@ import { SINGLE_JOB } from './constants'; */ export default { + i18n: { + bridgeBadgeText: __('Trigger job'), + unauthorizedTooltip: __('You are not authorized to run this manual job'), + }, hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', components: { ActionComponent, CiIcon, JobNameComponent, + GlBadge, GlLink, }, directives: { @@ -113,6 +118,12 @@ export default { isSingleItem() { return this.type === SINGLE_JOB; }, + isBridge() { + return this.kind === BRIDGE_KIND; + }, + kind() { + return this.job?.kind || ''; + }, nameComponent() { return this.hasDetails ? 'gl-link' : 'div'; }, @@ -187,6 +198,7 @@ export default { [this.$options.hoverClass]: this.relatedDownstreamHovered || this.relatedDownstreamExpanded, }, + { 'gl-rounded-lg': this.isBridge }, this.cssClassJobName, ]; }, @@ -213,9 +225,6 @@ export default { this.$emit('pipelineActionRequestComplete'); }, }, - i18n: { - unauthorizedTooltip: __('You are not authorized to run this manual job'), - }, }; </script> <template> @@ -253,6 +262,9 @@ export default { </div> </div> </div> + <gl-badge v-if="isBridge" class="gl-mt-3" variant="info" size="sm"> + {{ $options.i18n.bridgeBadgeText }} + </gl-badge> </component> <action-component diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index d59802196af..9f76d4cec50 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -1,10 +1,22 @@ <script> -import { GlBadge, GlButton, GlLink, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { + GlBadge, + GlButton, + GlLink, + GlLoadingIcon, + GlTooltip, + GlTooltipDirective, +} from '@gitlab/ui'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; +import CancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; +import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { PIPELINE_GRAPHQL_TYPE } from '../../constants'; import { reportToSentry } from '../../utils'; -import { DOWNSTREAM, UPSTREAM } from './constants'; +import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from './constants'; export default { directives: { @@ -16,7 +28,14 @@ export default { GlButton, GlLink, GlLoadingIcon, + GlTooltip, }, + styles: { + actionSizeClasses: ['gl-h-7 gl-w-7'], + flatLeftBorder: ['gl-rounded-bottom-left-none!', 'gl-rounded-top-left-none!'], + flatRightBorder: ['gl-rounded-bottom-right-none!', 'gl-rounded-top-right-none!'], + }, + mixins: [glFeatureFlagMixin()], props: { columnTitle: { type: String, @@ -39,15 +58,44 @@ export default { required: true, }, }, + data() { + return { + hasActionTooltip: false, + isActionLoading: false, + }; + }, computed: { - buttonBorderClass() { - return this.isUpstream ? 'gl-border-r-1!' : 'gl-border-l-1!'; + action() { + if (this.glFeatures?.downstreamRetryAction && this.isDownstream) { + if (this.isCancelable) { + return { + icon: 'cancel', + method: this.cancelPipeline, + ariaLabel: __('Cancel downstream pipeline'), + }; + } else if (this.isRetryable) { + return { + icon: 'retry', + method: this.retryPipeline, + ariaLabel: __('Retry downstream pipeline'), + }; + } + } + + return {}; + }, + buttonBorderClasses() { + return this.isUpstream + ? ['gl-border-r-0!', ...this.$options.styles.flatRightBorder] + : ['gl-border-l-0!', ...this.$options.styles.flatLeftBorder]; }, buttonId() { return `js-linked-pipeline-${this.pipeline.id}`; }, - cardSpacingClass() { - return this.isDownstream ? 'gl-pr-0' : ''; + cardClasses() { + return this.isDownstream + ? this.$options.styles.flatRightBorder + : this.$options.styles.flatLeftBorder; }, expandedIcon() { if (this.isUpstream) { @@ -64,9 +112,21 @@ export default { flexDirection() { return this.isUpstream ? 'gl-flex-direction-row-reverse' : 'gl-flex-direction-row'; }, + graphqlPipelineId() { + return convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, this.pipeline.id); + }, + hasUpdatePipelinePermissions() { + return Boolean(this.pipeline?.userPermissions?.updatePipeline); + }, + isCancelable() { + return Boolean(this.pipeline?.cancelable && this.hasUpdatePipelinePermissions); + }, isDownstream() { return this.type === DOWNSTREAM; }, + isRetryable() { + return Boolean(this.pipeline?.retryable && this.hasUpdatePipelinePermissions); + }, isSameProject() { return !this.pipeline.multiproject; }, @@ -93,13 +153,19 @@ export default { projectName() { return this.pipeline.project.name; }, + showAction() { + return Boolean(this.action?.method && this.action?.icon && this.action?.ariaLabel); + }, + showCardTooltip() { + return !this.hasActionTooltip; + }, sourceJobName() { return this.pipeline.sourceJob?.name ?? ''; }, sourceJobInfo() { return this.isDownstream ? sprintf(__('Created by %{job}'), { job: this.sourceJobName }) : ''; }, - tooltipText() { + cardTooltipText() { return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} - ${this.sourceJobInfo}`; }, @@ -108,6 +174,26 @@ export default { reportToSentry('linked_pipeline', `error: ${err}, info: ${info}`); }, methods: { + cancelPipeline() { + this.executePipelineAction(CancelPipelineMutation); + }, + async executePipelineAction(mutation) { + try { + this.isActionLoading = true; + + await this.$apollo.mutate({ + mutation, + variables: { + id: this.graphqlPipelineId, + }, + }); + this.$emit('refreshPipelineGraph'); + } catch { + this.$emit('error', { type: ACTION_FAILURE }); + } finally { + this.isActionLoading = false; + } + }, hideTooltips() { this.$root.$emit(BV_HIDE_TOOLTIP); }, @@ -122,6 +208,12 @@ export default { onDownstreamHoverLeave() { this.$emit('downstreamHovered', ''); }, + retryPipeline() { + this.executePipelineAction(RetryPipelineMutation); + }, + setActionTooltip(flag) { + this.hasActionTooltip = flag; + }, }, }; </script> @@ -129,33 +221,48 @@ export default { <template> <div ref="linkedPipeline" - v-gl-tooltip - class="gl-h-full gl-display-flex! gl-border-solid gl-border-gray-100 gl-border-1" + class="gl-h-full gl-display-flex!" :class="flexDirection" - :title="tooltipText" data-qa-selector="linked_pipeline_container" @mouseover="onDownstreamHovered" @mouseleave="onDownstreamHoverLeave" > - <div class="gl-w-full gl-bg-white gl-p-3" :class="cardSpacingClass"> - <div class="gl-display-flex gl-pr-3"> - <ci-status - v-if="!pipelineIsLoading" - :status="pipelineStatus" - :size="24" - css-classes="gl-top-0 gl-pr-2" - /> + <gl-tooltip v-if="showCardTooltip" :target="() => $refs.linkedPipeline"> + {{ cardTooltipText }} + </gl-tooltip> + <div class="gl-bg-white gl-border gl-p-3 gl-rounded-lg gl-w-full" :class="cardClasses"> + <div class="gl-display-flex gl-gap-x-3"> + <ci-status v-if="!pipelineIsLoading" :status="pipelineStatus" :size="24" css-classes="" /> <div v-else class="gl-pr-3"><gl-loading-icon size="sm" inline /></div> - <div class="gl-display-flex gl-flex-direction-column gl-downstream-pipeline-job-width"> + <div + class="gl-display-flex gl-downstream-pipeline-job-width gl-flex-direction-column gl-line-height-normal" + > <span class="gl-text-truncate" data-testid="downstream-title"> {{ downstreamTitle }} </span> <div class="gl-text-truncate"> - <gl-link class="gl-text-blue-500!" :href="pipeline.path" data-testid="pipelineLink" + <gl-link + class="gl-text-blue-500! gl-font-sm" + :href="pipeline.path" + data-testid="pipelineLink" >#{{ pipeline.id }}</gl-link > </div> </div> + <gl-button + v-if="showAction" + v-gl-tooltip + :title="action.ariaLabel" + :loading="isActionLoading" + :icon="action.icon" + class="gl-rounded-full!" + :class="$options.styles.actionSizeClasses" + :aria-label="action.ariaLabel" + @click="action.method" + @mouseover="setActionTooltip(true)" + @mouseout="setActionTooltip(false)" + /> + <div v-else :class="$options.styles.actionSizeClasses"></div> </div> <div class="gl-pt-2"> <gl-badge size="sm" variant="info" data-testid="downstream-pipeline-label"> @@ -166,8 +273,8 @@ export default { <div class="gl-display-flex"> <gl-button :id="buttonId" - class="gl-shadow-none! gl-rounded-0!" - :class="`js-pipeline-expand-${pipeline.id} ${buttonBorderClass}`" + class="gl-border! gl-shadow-none! gl-rounded-lg!" + :class="[`js-pipeline-expand-${pipeline.id}`, buttonBorderClasses]" :icon="expandedIcon" :aria-label="__('Expand pipeline')" data-testid="expand-pipeline-button" diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index 3c1208afbf0..b06c2f15042 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -66,14 +66,13 @@ export default { columnClass() { const positionValues = { right: 'gl-ml-6', - left: 'gl-mr-6', + left: 'gl-mx-6', }; + return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; }, computedTitleClasses() { - const positionalClasses = this.isUpstream - ? ['gl-w-full', 'gl-text-right', 'gl-linked-pipeline-padding'] - : []; + const positionalClasses = this.isUpstream ? ['gl-w-full', 'gl-linked-pipeline-padding'] : []; return [...this.$options.titleClasses, ...positionalClasses]; }, @@ -202,7 +201,7 @@ export default { <li v-for="pipeline in linkedPipelines" :key="pipeline.id" - class="gl-display-flex gl-mb-4" + class="gl-display-flex gl-mb-3" :class="{ 'gl-flex-direction-row-reverse': isUpstream }" > <linked-pipeline @@ -215,6 +214,7 @@ export default { @downstreamHovered="onDownstreamHovered" @pipelineClicked="onPipelineClick(pipeline)" @pipelineExpandToggle="onPipelineExpandToggle" + @refreshPipelineGraph="$emit('refreshPipelineGraph')" /> <div v-if="showContainer(pipeline.id)" diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 04b78b8aa23..37878f3fb6d 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -93,6 +93,7 @@ export default { data() { return { pipeline: null, + failureMessages: [], failureType: null, isCanceling: false, isRetrying: false, @@ -159,8 +160,9 @@ export default { }, }, methods: { - reportFailure(errorType) { + reportFailure(errorType, errorMessages = []) { this.failureType = errorType; + this.failureMessages = errorMessages; }, async postPipelineAction(name, mutation) { try { @@ -176,7 +178,7 @@ export default { if (errors.length > 0) { this.isRetrying = false; - this.reportFailure(POST_FAILURE); + this.reportFailure(POST_FAILURE, errors); } else { await this.$apollo.queries.pipeline.refetch(); if (!this.isFinished) { @@ -214,7 +216,7 @@ export default { }); if (errors.length > 0) { - this.reportFailure(DELETE_FAILURE); + this.reportFailure(DELETE_FAILURE, errors); this.isDeleting = false; } else { redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success')); @@ -231,9 +233,11 @@ export default { </script> <template> <div class="js-pipeline-header-container"> - <gl-alert v-if="hasError" :variant="failure.variant" :dismissible="false">{{ - failure.text - }}</gl-alert> + <gl-alert v-if="hasError" :title="failure.text" :variant="failure.variant" :dismissible="false"> + <div v-for="(failureMessage, index) in failureMessages" :key="`failure-message-${index}`"> + {{ failureMessage }} + </div> + </gl-alert> <ci-header v-if="shouldRenderContent" :status="pipeline.detailedStatus" @@ -261,6 +265,7 @@ export default { v-if="canCancelPipeline" :loading="isCanceling" :disabled="isCanceling" + class="gl-ml-3" variant="danger" data-testid="cancelPipeline" @click="cancelPipeline()" diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue new file mode 100644 index 00000000000..9e886fd7a48 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue @@ -0,0 +1,73 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import GetFailedJobsQuery from '../../graphql/queries/get_failed_jobs.query.graphql'; +import { prepareFailedJobs } from './utils'; +import FailedJobsTable from './failed_jobs_table.vue'; + +export default { + components: { + GlLoadingIcon, + FailedJobsTable, + }, + inject: { + fullPath: { + default: '', + }, + pipelineIid: { + default: '', + }, + }, + props: { + failedJobsSummary: { + type: Array, + required: true, + }, + }, + apollo: { + failedJobs: { + query: GetFailedJobsQuery, + variables() { + return { + fullPath: this.fullPath, + pipelineIid: this.pipelineIid, + }; + }, + update({ project }) { + if (project?.pipeline?.jobs?.nodes) { + return project.pipeline.jobs.nodes.map((job) => { + return { normalizedId: getIdFromGraphQLId(job.id), ...job }; + }); + } + return []; + }, + result() { + this.preparedFailedJobs = prepareFailedJobs(this.failedJobs, this.failedJobsSummary); + }, + error() { + createFlash({ message: s__('Jobs|There was a problem fetching the failed jobs.') }); + }, + }, + }, + data() { + return { + failedJobs: [], + preparedFailedJobs: [], + }; + }, + computed: { + loading() { + return this.$apollo.queries.failedJobs.loading; + }, + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="loading" size="lg" class="gl-mt-4" /> + <failed-jobs-table v-else :failed-jobs="preparedFailedJobs" /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue new file mode 100644 index 00000000000..1c646bdf3d6 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue @@ -0,0 +1,111 @@ +<script> +import { GlButton, GlLink, GlSafeHtmlDirective, GlTableLite } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import createFlash from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import RetryFailedJobMutation from '../../graphql/mutations/retry_failed_job.mutation.graphql'; +import { DEFAULT_FIELDS } from '../../constants'; + +export default { + fields: DEFAULT_FIELDS, + retry: __('Retry'), + components: { + CiBadge, + GlButton, + GlLink, + GlTableLite, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + props: { + failedJobs: { + type: Array, + required: true, + }, + }, + methods: { + async retryJob(id) { + try { + const { + data: { + jobRetry: { errors, job }, + }, + } = await this.$apollo.mutate({ + mutation: RetryFailedJobMutation, + variables: { id }, + }); + if (errors.length > 0) { + this.showErrorMessage(); + } else { + redirectTo(job.detailedStatus.detailsPath); + } + } catch { + this.showErrorMessage(); + } + }, + canRetryJob(job) { + return job.retryable && job.userPermissions.updateBuild; + }, + showErrorMessage() { + createFlash({ message: s__('Job|There was a problem retrying the failed job.') }); + }, + }, +}; +</script> + +<template> + <gl-table-lite :items="failedJobs" :fields="$options.fields" stacked="lg" fixed> + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + + <template #cell(name)="{ item }"> + <div + class="gl-display-flex gl-align-items-center gl-lg-justify-content-start gl-justify-content-end" + > + <ci-badge :status="item.detailedStatus" :show-text="false" class="gl-mr-3" /> + <div class="gl-text-truncate"> + <gl-link + :href="item.detailedStatus.detailsPath" + class="gl-font-weight-bold gl-text-gray-900!" + > + {{ item.name }} + </gl-link> + </div> + </div> + </template> + + <template #cell(stage)="{ item }"> + <div class="gl-text-truncate"> + <span>{{ item.stage.name }}</span> + </div> + </template> + + <template #cell(failure)="{ item }"> + <span>{{ item.failure }}</span> + </template> + + <template #cell(actions)="{ item }"> + <gl-button + v-if="canRetryJob(item)" + icon="repeat" + :title="$options.retry" + :aria-label="$options.retry" + @click="retryJob(item.id)" + /> + </template> + + <template #row-details="{ item }"> + <pre + v-if="item.userPermissions.readBuild" + class="gl-w-full gl-text-left gl-border-none" + data-testid="job-log" + > + <code v-safe-html="item.failureSummary" class="gl-reset-bg gl-p-0" > + </code> + </pre> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/pipelines/components/jobs/utils.js b/app/assets/javascripts/pipelines/components/jobs/utils.js new file mode 100644 index 00000000000..c8414d44d14 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/jobs/utils.js @@ -0,0 +1,33 @@ +/* + We get the failure and failure summary from Rails which has + a summary failure log. Here we combine that data with the data + from GraphQL to display the log. + + failedJobs is from GraphQL + failedJobsSummary is from Rails + */ + +export const prepareFailedJobs = (failedJobs = [], failedJobsSummary = []) => { + const combinedJobs = []; + + if (failedJobs.length > 0 && failedJobsSummary.length > 0) { + failedJobs.forEach((failedJob) => { + const foundJob = failedJobsSummary.find( + (failedJobSummary) => failedJob.normalizedId === failedJobSummary.id, + ); + + if (foundJob) { + combinedJobs.push({ + ...failedJob, + failure: foundJob?.failure, + failureSummary: foundJob?.failure_summary, + // this field is needed for the slot row-details + // on the failed_jobs_table.vue component + _showDetails: true, + }); + } + }); + } + + return combinedJobs; +}; diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue index 62c785d7ad2..66d30c10362 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue @@ -1,6 +1,7 @@ <script> import { GlTabs, GlTab } from '@gitlab/ui'; import { __ } from '~/locale'; +import { failedJobsTabName, jobsTabName, needsTabName, testReportTabName } from '../constants'; import PipelineGraphWrapper from './graph/graph_component_wrapper.vue'; import Dag from './dag/dag.vue'; import JobsApp from './jobs/jobs_app.vue'; @@ -16,6 +17,12 @@ export default { testsTitle: __('Tests'), }, }, + tabNames: { + needs: needsTabName, + jobs: jobsTabName, + failures: failedJobsTabName, + tests: testReportTabName, + }, components: { Dag, GlTab, @@ -25,24 +32,47 @@ export default { PipelineGraphWrapper, TestReports, }, + inject: ['defaultTabValue'], + methods: { + isActive(tabName) { + return tabName === this.defaultTabValue; + }, + }, }; </script> <template> <gl-tabs> - <gl-tab :title="$options.i18n.tabs.pipelineTitle" data-testid="pipeline-tab"> + <gl-tab ref="pipelineTab" :title="$options.i18n.tabs.pipelineTitle" data-testid="pipeline-tab"> <pipeline-graph-wrapper /> </gl-tab> - <gl-tab :title="$options.i18n.tabs.needsTitle" data-testid="dag-tab"> + <gl-tab + ref="dagTab" + :title="$options.i18n.tabs.needsTitle" + :active="isActive($options.tabNames.needs)" + data-testid="dag-tab" + > <dag /> </gl-tab> - <gl-tab :title="$options.i18n.tabs.jobsTitle" data-testid="jobs-tab"> + <gl-tab + :title="$options.i18n.tabs.jobsTitle" + :active="isActive($options.tabNames.jobs)" + data-testid="jobs-tab" + > <jobs-app /> </gl-tab> - <gl-tab :title="$options.i18n.tabs.failedJobsTitle" data-testid="failed-jobs-tab"> + <gl-tab + :title="$options.i18n.tabs.failedJobsTitle" + :active="isActive($options.tabNames.failures)" + data-testid="failed-jobs-tab" + > <failed-jobs-app /> </gl-tab> - <gl-tab :title="$options.i18n.tabs.testsTitle" data-testid="tests-tab"> + <gl-tab + :title="$options.i18n.tabs.testsTitle" + :active="isActive($options.tabNames.tests)" + data-testid="tests-tab" + > <test-reports /> </gl-tab> <slot></slot> 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 5a9c85a0f10..3bbdfc73e1b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -1,7 +1,9 @@ <script> import { GlEmptyState } from '@gitlab/ui'; import { s__ } from '~/locale'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import PipelinesCiTemplates from './empty_state/pipelines_ci_templates.vue'; +import IosTemplates from './empty_state/ios_templates.vue'; export default { i18n: { @@ -10,7 +12,9 @@ export default { name: 'PipelinesEmptyState', components: { GlEmptyState, + GitlabExperiment, PipelinesCiTemplates, + IosTemplates, }, props: { emptyStateSvgPath: { @@ -21,26 +25,24 @@ export default { type: Boolean, required: true, }, - ciRunnerSettingsPath: { + registrationToken: { type: String, required: false, default: null, }, - anyRunnersAvailable: { - type: Boolean, - required: false, - default: true, - }, }, }; </script> <template> <div> - <pipelines-ci-templates - v-if="canSetCi" - :ci-runner-settings-path="ciRunnerSettingsPath" - :any-runners-available="anyRunnersAvailable" - /> + <gitlab-experiment v-if="canSetCi" name="ios_specific_templates"> + <template #control> + <pipelines-ci-templates /> + </template> + <template #candidate> + <ios-templates :registration-token="registrationToken" /> + </template> + </gitlab-experiment> <gl-empty-state v-else title="" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue index 3b312e78d11..64d4414eb94 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue @@ -12,15 +12,31 @@ export default { }, mixins: [Tracking.mixin()], inject: ['pipelineEditorPath', 'suggestedCiTemplates'], + props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, + filterTemplates: { + type: Array, + required: false, + default: () => [], + }, + }, data() { - const templates = this.suggestedCiTemplates.map(({ name, logo }) => { - return { - name, - logo, - link: mergeUrlParams({ template: name }, this.pipelineEditorPath), - description: sprintf(this.$options.i18n.description, { name }), - }; - }); + const templates = this.suggestedCiTemplates + .filter( + (template) => !this.filterTemplates.length || this.filterTemplates.includes(template.name), + ) + .map(({ name, logo, title }) => { + return { + name: title || name, + logo, + link: mergeUrlParams({ template: name }, this.pipelineEditorPath), + description: sprintf(this.$options.i18n.description, { name: title || name }), + }; + }); return { templates, @@ -34,7 +50,9 @@ export default { }, }, i18n: { - description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'), + description: s__( + 'Pipelines|Continuous integration and deployment template to test and deploy your %{name} project.', + ), cta: s__('Pipelines|Use template'), }, AVATAR_SHAPE_OPTION_RECT, @@ -67,6 +85,7 @@ export default { </div> </div> <gl-button + :disabled="disabled" category="primary" variant="confirm" :href="template.link" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue new file mode 100644 index 00000000000..8ff311e90e7 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue @@ -0,0 +1,220 @@ +<script> +import { GlButton, GlCard, GlSprintf, GlLink, GlPopover, GlModalDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import apolloProvider from '~/pipelines/graphql/provider'; +import CiTemplates from './ci_templates.vue'; + +export default { + components: { + GlButton, + GlCard, + GlSprintf, + GlLink, + GlPopover, + RunnerInstructionsModal, + CiTemplates, + }, + directives: { + GlModalDirective, + }, + inject: ['pipelineEditorPath', 'iosRunnersAvailable'], + props: { + registrationToken: { + type: String, + required: false, + default: null, + }, + }, + apolloProvider, + iOSTemplateName: 'iOS-Fastlane', + modalId: 'runner-instructions-modal', + runnerDocsLink: 'https://docs.gitlab.com/runner/install/osx', + whatElseLink: helpPagePath('ci/index.md'), + i18n: { + title: s__('Pipelines|Get started with GitLab CI/CD'), + subtitle: s__('Pipelines|Building for iOS?'), + explanation: s__("Pipelines|We'll walk you through how to deploy to iOS in two easy steps."), + runnerSetupTitle: s__('Pipelines|1. Set up a runner'), + runnerSetupButton: s__('Pipelines|Set up a runner'), + runnerSetupBodyUnfinished: s__( + 'Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline.', + ), + runnerSetupBodyFinished: s__( + 'Pipelines|You have runners available to run your job now. No need to do anything else.', + ), + runnerSetupPopoverTitle: s__( + "Pipelines|Let's get that runner set up! %{emojiStart}tada%{emojiEnd}", + ), + runnerSetupPopoverBodyLine1: s__( + 'Pipelines|Follow these instructions to install GitLab Runner on macOS.', + ), + runnerSetupPopoverBodyLine2: s__( + 'Pipelines|Need more information to set up your runner? %{linkStart}Check out our documentation%{linkEnd}.', + ), + configurePipelineTitle: s__('Pipelines|2. Configure deployment pipeline'), + configurePipelineBody: s__("Pipelines|We'll guide you through a simple pipeline set-up."), + configurePipelineButton: s__('Pipelines|Configure pipeline'), + noWalkthroughTitle: s__("Pipelines|Don't need a guide? Jump in right away with a template."), + noWalkthroughExplanation: s__('Pipelines|Based on your project, we recommend this template:'), + notBuildingForIos: s__( + "Pipelines|Not building for iOS or not what you're looking for? %{linkStart}See what else%{linkEnd} GitLab CI/CD has to offer.", + ), + }, + data() { + return { + isModalShown: false, + isPopoverShown: false, + isRunnerSetupFinished: this.iosRunnersAvailable, + popoverTarget: `${this.$options.modalId}___BV_modal_content_`, + configurePipelineLink: mergeUrlParams( + { template: this.$options.iOSTemplateName }, + this.pipelineEditorPath, + ), + }; + }, + computed: { + runnerSetupBodyText() { + return this.iosRunnersAvailable + ? this.$options.i18n.runnerSetupBodyFinished + : this.$options.i18n.runnerSetupBodyUnfinished; + }, + }, + methods: { + showModal() { + this.isModalShown = true; + }, + hideModal() { + this.togglePopover(); + this.isRunnerSetupFinished = true; + }, + togglePopover() { + this.isPopoverShown = !this.isPopoverShown; + }, + }, +}; +</script> + +<template> + <div> + <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.title }}</h2> + <h3 class="gl-font-lg gl-text-gray-900 gl-mt-1">{{ $options.i18n.subtitle }}</h3> + <p>{{ $options.i18n.explanation }}</p> + + <div class="gl-lg-display-flex"> + <div class="gl-lg-display-flex gl-lg-w-25p gl-lg-pr-4 gl-mb-4"> + <gl-card body-class="gl-display-flex gl-flex-grow-1"> + <div + class="gl-display-flex gl-flex-grow-1 gl-flex-direction-column gl-justify-content-space-between gl-align-items-flex-start" + > + <div> + <div class="gl-py-5"> + <gl-emoji + v-show="isRunnerSetupFinished" + class="gl-font-size-h2-xl" + data-name="white_check_mark" + data-testid="runner-setup-marked-completed" + /> + <gl-emoji + v-show="!isRunnerSetupFinished" + class="gl-font-size-h2-xl" + data-name="tools" + data-testid="runner-setup-marked-todo" + /> + </div> + <span class="gl-text-gray-800 gl-font-weight-bold"> + {{ $options.i18n.runnerSetupTitle }} + </span> + <p class="gl-font-sm gl-mt-3">{{ runnerSetupBodyText }}</p> + </div> + + <gl-button + v-if="!iosRunnersAvailable" + v-gl-modal-directive="$options.modalId" + category="primary" + variant="confirm" + @click="showModal" + > + {{ $options.i18n.runnerSetupButton }} + </gl-button> + <runner-instructions-modal + v-if="isModalShown" + :modal-id="$options.modalId" + :registration-token="registrationToken" + default-platform-name="osx" + @shown="togglePopover" + @hide="hideModal" + /> + <gl-popover + v-if="isPopoverShown" + :show="true" + :show-close-button="true" + :target="popoverTarget" + triggers="manual" + placement="left" + fallback-placement="clockwise" + > + <template #title> + <gl-sprintf :message="$options.i18n.runnerSetupPopoverTitle"> + <template #emoji="{ content }"> + <gl-emoji class="gl-ml-2" :data-name="content" /> + </template> + </gl-sprintf> + </template> + <div class="gl-mb-5"> + {{ $options.i18n.runnerSetupPopoverBodyLine1 }} + </div> + <gl-sprintf :message="$options.i18n.runnerSetupPopoverBodyLine2"> + <template #link="{ content }"> + <gl-link :href="$options.runnerDocsLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-popover> + </div> + </gl-card> + </div> + <div class="gl-lg-display-flex gl-lg-w-25p gl-lg-pr-4 gl-mb-4"> + <gl-card body-class="gl-display-flex gl-flex-grow-1"> + <div + class="gl-display-flex gl-flex-grow-1 gl-flex-direction-column gl-justify-content-space-between gl-align-items-flex-start" + > + <div> + <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="tools" /></div> + <span class="gl-text-gray-800 gl-font-weight-bold"> + {{ $options.i18n.configurePipelineTitle }} + </span> + <p class="gl-font-sm gl-mt-3">{{ $options.i18n.configurePipelineBody }}</p> + </div> + + <gl-button + :disabled="!isRunnerSetupFinished" + category="primary" + variant="confirm" + data-testid="configure-pipeline-link" + :href="configurePipelineLink" + > + {{ $options.i18n.configurePipelineButton }} + </gl-button> + </div> + </gl-card> + </div> + </div> + <h3 class="gl-font-lg gl-text-gray-900 gl-mt-5">{{ $options.i18n.noWalkthroughTitle }}</h3> + <p>{{ $options.i18n.noWalkthroughExplanation }}</p> + <ci-templates + :filter-templates="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ + $options.iOSTemplateName, + ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :disabled="!isRunnerSetupFinished" + /> + <p> + <gl-sprintf :message="$options.i18n.notBuildingForIos"> + <template #link="{ content }"> + <gl-link :href="$options.whatElseLink">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue index be46a7f5cec..3eafb36bd1d 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue @@ -33,19 +33,7 @@ export default { RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, I18N, - inject: ['pipelineEditorPath'], - props: { - ciRunnerSettingsPath: { - type: String, - required: false, - default: null, - }, - anyRunnersAvailable: { - type: Boolean, - required: false, - default: true, - }, - }, + inject: ['anyRunnersAvailable', 'pipelineEditorPath', 'ciRunnerSettingsPath'], data() { return { gettingStartedTemplateUrl: mergeUrlParams( diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue index 2b33467e948..e35fccf2d7e 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue @@ -36,7 +36,7 @@ export default { }; </script> <template> - <div data-testid="pipeline-mini-graph"> + <div data-testid="pipeline-mini-graph" class="gl-display-inline gl-vertical-align-middle gl-my-1"> <div v-for="stage in stages" :key="stage.name" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue index afcb04cd7eb..53e21d4ce8b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue @@ -12,7 +12,8 @@ * 4. Commit widget */ -import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; @@ -21,7 +22,7 @@ import JobItem from './job_item.vue'; export default { components: { - GlIcon, + CiIcon, GlLoadingIcon, GlDropdown, JobItem, @@ -51,14 +52,6 @@ export default { dropdownContent: [], }; }, - computed: { - triggerButtonClass() { - return `ci-status-icon-${this.stage.status.group}`; - }, - borderlessIcon() { - return `${this.stage.status.icon}_borderless`; - }, - }, watch: { updateDropdown() { if (this.updateDropdown && this.isDropdownOpen() && !this.isLoading) { @@ -114,15 +107,21 @@ export default { variant="link" :aria-label="stageAriaLabel(stage.title)" :lazy="true" - :popper-opts="{ placement: 'bottom' }" - :toggle-class="['mini-pipeline-graph-dropdown-toggle', triggerButtonClass]" + :popper-opts="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + placement: 'bottom', + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :toggle-class="['gl-rounded-full!']" menu-class="mini-pipeline-graph-dropdown-menu" @show="onShowDropdown" > <template #button-content> - <span class="gl-pointer-events-none"> - <gl-icon :name="borderlessIcon" /> - </span> + <ci-icon + is-interactive + css-classes="gl-rounded-full" + :size="24" + :status="stage.status" + class="gl-align-items-center gl-display-inline-flex" + /> </template> <gl-loading-icon v-if="isLoading" size="sm" /> <ul diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index db9dc74863d..485e338f639 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -107,16 +107,11 @@ export default { type: Object, required: true, }, - ciRunnerSettingsPath: { + registrationToken: { type: String, required: false, default: null, }, - anyRunnersAvailable: { - type: Boolean, - required: false, - default: true, - }, }, data() { return { @@ -386,8 +381,7 @@ export default { v-else-if="stateToRender === $options.stateMap.emptyState" :empty-state-svg-path="emptyStateSvgPath" :can-set-ci="canCreatePipeline" - :ci-runner-settings-path="ciRunnerSettingsPath" - :any-runners-available="anyRunnersAvailable" + :registration-token="registrationToken" /> <gl-empty-state 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 77b9c2b5203..53da98434b0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -174,12 +174,13 @@ export default { <div></div> <linked-pipelines-mini-list v-if="item.triggered_by" - :triggered-by="[item.triggered_by]" + :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ + item.triggered_by, + ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" data-testid="mini-graph-upstream" /> <pipeline-mini-graph v-if="item.details && item.details.stages && item.details.stages.length > 0" - class="gl-display-inline" :stages="item.details.stages" :update-dropdown="updateGraphDropdown" @pipelineActionRequestComplete="onPipelineActionRequestComplete" diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index 51373e712ff..9b0e6560c53 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -35,7 +35,7 @@ export default { }, computed: { ...mapState(['pageInfo']), - ...mapGetters(['getSuiteTests', 'getSuiteTestCount']), + ...mapGetters(['getSuiteTests', 'getSuiteTestCount', 'getSuiteArtifactsExpired']), hasSuites() { return this.getSuiteTests.length > 0; }, @@ -80,7 +80,8 @@ export default { <div v-for="(testCase, index) in getSuiteTests" :key="index" - class="gl-responsive-table-row rounded align-items-md-start mt-xs-3 js-case-row" + class="gl-responsive-table-row rounded align-items-md-start" + data-testid="test-case-row" > <div class="table-section section-20 section-wrap"> <div role="rowheader" class="table-mobile-header">{{ __('Suite') }}</div> @@ -157,7 +158,16 @@ export default { </div> <div v-else> - <p class="js-no-test-cases">{{ s__('TestReports|There are no test cases to display.') }}</p> + <p data-testid="no-test-cases"> + {{ s__('TestReports|There are no test cases to display.') }} + </p> + <p v-if="getSuiteArtifactsExpired" data-testid="artifacts-expired"> + {{ + s__( + 'TestReports|Test details are populated by job artifacts. The job artifacts from this pipeline are expired.', + ) + }} + </p> </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 36f708ff2af..0510992e962 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -9,6 +9,7 @@ export const FILTER_TAG_IDENTIFIER = 'tag'; export const SCHEDULE_ORIGIN = 'schedule'; export const NEEDS_PROPERTY = 'needs'; export const EXPLICIT_NEEDS_PROPERTY = 'previousStageJobsOrNeeds'; +export const PIPELINE_GRAPHQL_TYPE = 'Ci::Pipeline'; export const ICONS = { TAG: 'tag', @@ -44,6 +45,28 @@ export const UNSUPPORTED_DATA = 'unsupported_data'; export const CHILD_VIEW = 'child'; +// Pipeline tabs + +export const TAB_QUERY_PARAM = 'tab'; + +export const needsTabName = 'dag'; +export const jobsTabName = 'builds'; +export const failedJobsTabName = 'failures'; +export const testReportTabName = 'test_report'; +export const securityTabName = 'security'; +export const licensesTabName = 'licenses'; +export const codeQualityTabName = 'codequality_report'; + +export const validPipelineTabNames = [ + needsTabName, + jobsTabName, + failedJobsTabName, + testReportTabName, + securityTabName, + licensesTabName, + codeQualityTabName, +]; + // Constants for the ID and IID selection dropdown export const PipelineKeyOptions = [ { @@ -62,3 +85,27 @@ export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.'); export const BUTTON_TOOLTIP_RETRY = __('Retry failed jobs'); export const BUTTON_TOOLTIP_CANCEL = __('Cancel'); + +export const DEFAULT_FIELDS = [ + { + key: 'name', + label: __('Name'), + columnClass: 'gl-w-20p', + }, + { + key: 'stage', + label: __('Stage'), + columnClass: 'gl-w-20p', + }, + { + key: 'failure', + label: __('Failure'), + columnClass: 'gl-w-40p', + }, + { + key: 'actions', + label: '', + tdClass: 'gl-text-right', + columnClass: 'gl-w-20p', + }, +]; diff --git a/app/assets/javascripts/pipelines/graphql/mutations/retry_failed_job.mutation.graphql b/app/assets/javascripts/pipelines/graphql/mutations/retry_failed_job.mutation.graphql new file mode 100644 index 00000000000..1955cc9b0ac --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/mutations/retry_failed_job.mutation.graphql @@ -0,0 +1,12 @@ +mutation retryFailedJob($id: CiBuildID!) { + jobRetry(input: { id: $id }) { + job { + id + detailedStatus { + id + detailsPath + } + } + errors + } +} diff --git a/app/assets/javascripts/pipelines/graphql/provider.js b/app/assets/javascripts/pipelines/graphql/provider.js new file mode 100644 index 00000000000..ef96b443da8 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/provider.js @@ -0,0 +1,9 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +export default new VueApollo({ + defaultClient: createDefaultClient(), +}); diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql new file mode 100644 index 00000000000..14e9a838f4b --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql @@ -0,0 +1,41 @@ +query getFailedJobs($fullPath: ID!, $pipelineIid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $pipelineIid) { + id + jobs(statuses: FAILED) { + nodes { + status + detailedStatus { + id + detailsPath + group + icon + label + text + tooltip + action { + id + buttonTitle + icon + method + path + title + } + } + id + stage { + id + name + } + name + retryable + userPermissions { + readBuild + updateBuild + } + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 338de65e795..fd869014570 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -1,10 +1,11 @@ import createFlash from '~/flash'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import createDagApp from './pipeline_details_dag'; import { createPipelinesDetailApp } from './pipeline_details_graph'; import { createPipelineHeaderApp } from './pipeline_details_header'; import { createPipelineNotificationApp } from './pipeline_details_notification'; import { createPipelineJobsApp } from './pipeline_details_jobs'; +import { createPipelineFailedJobsApp } from './pipeline_details_failed_jobs'; import { apolloProvider } from './pipeline_shared_client'; import { createTestDetails } from './pipeline_test_details'; @@ -16,6 +17,7 @@ const SELECTORS = { PIPELINE_TABS: '#js-pipeline-tabs', PIPELINE_TESTS: '#js-pipeline-tests-detail', PIPELINE_JOBS: '#js-pipeline-jobs-vue', + PIPELINE_FAILED_JOBS: '#js-pipeline-failed-jobs-vue', }; export default async function initPipelineDetailsBundle() { @@ -79,5 +81,15 @@ export default async function initPipelineDetailsBundle() { message: __('An error occurred while loading the Jobs tab.'), }); } + + if (gon.features?.failedJobsTabVue) { + try { + createPipelineFailedJobsApp(SELECTORS.PIPELINE_FAILED_JOBS); + } catch { + createFlash({ + message: s__('Jobs|An error occurred while loading the Failed Jobs tab.'), + }); + } + } } } diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js index e2835ecc4d1..b2cb0457c4d 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_dag.js +++ b/app/assets/javascripts/pipelines/pipeline_details_dag.js @@ -17,7 +17,7 @@ const createDagApp = (apolloProvider) => { emptySvgPath, pipelineProjectPath, pipelineIid, - } = el?.dataset; + } = el.dataset; // eslint-disable-next-line no-new new Vue({ diff --git a/app/assets/javascripts/pipelines/pipeline_details_failed_jobs.js b/app/assets/javascripts/pipelines/pipeline_details_failed_jobs.js new file mode 100644 index 00000000000..7bf3b64bf47 --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_failed_jobs.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import FailedJobsApp from './components/jobs/failed_jobs_app.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const createPipelineFailedJobsApp = (selector) => { + const containerEl = document.querySelector(selector); + + if (!containerEl) { + return false; + } + + const { fullPath, pipelineIid, failedJobsSummaryData } = containerEl.dataset; + + return new Vue({ + el: containerEl, + apolloProvider, + provide: { + fullPath, + pipelineIid, + }, + render(createElement) { + return createElement(FailedJobsApp, { + props: { + failedJobsSummary: JSON.parse(failedJobsSummaryData), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js index 1c619768764..2fedd7e7a98 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_header.js +++ b/app/assets/javascripts/pipelines/pipeline_details_header.js @@ -11,7 +11,7 @@ export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResou return; } - const { fullPath, pipelineId, pipelineIid, pipelinesPath } = el?.dataset; + const { fullPath, pipelineId, pipelineIid, pipelinesPath } = el.dataset; // eslint-disable-next-line no-new new Vue({ el, diff --git a/app/assets/javascripts/pipelines/pipeline_details_notification.js b/app/assets/javascripts/pipelines/pipeline_details_notification.js index 0061be843c5..b480fc7c713 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_notification.js +++ b/app/assets/javascripts/pipelines/pipeline_details_notification.js @@ -11,7 +11,7 @@ export const createPipelineNotificationApp = (elSelector, apolloProvider) => { return; } - const { deprecatedKeywordsDocPath, fullPath, pipelineIid } = el?.dataset; + const { deprecatedKeywordsDocPath, fullPath, pipelineIid } = el.dataset; // eslint-disable-next-line no-new new Vue({ el, diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js index ff88c6215e5..530917f0402 100644 --- a/app/assets/javascripts/pipelines/pipeline_tabs.js +++ b/app/assets/javascripts/pipelines/pipeline_tabs.js @@ -1,7 +1,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import PipelineTabs from 'ee_else_ce/pipelines/components/pipeline_tabs.vue'; -import { reportToSentry } from './utils'; +import { removeParams, updateHistory } from '~/lib/utils/url_utility'; +import { TAB_QUERY_PARAM } from '~/pipelines/constants'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { getPipelineDefaultTab, reportToSentry } from './utils'; Vue.use(VueApollo); @@ -17,7 +20,19 @@ const createPipelineTabs = (selector, apolloProvider) => { downloadablePathForReportType, exposeSecurityDashboard, exposeLicenseScanningData, + graphqlResourceEtag, + pipelineIid, + pipelineProjectPath, } = dataset; + + const defaultTabValue = getPipelineDefaultTab(window.location.href); + + updateHistory({ + url: removeParams([TAB_QUERY_PARAM]), + title: document.title, + replace: true, + }); + // eslint-disable-next-line no-new new Vue({ el: selector, @@ -26,11 +41,15 @@ const createPipelineTabs = (selector, apolloProvider) => { }, apolloProvider, provide: { - canGenerateCodequalityReports: JSON.parse(canGenerateCodequalityReports), + canGenerateCodequalityReports: parseBoolean(canGenerateCodequalityReports), codequalityReportDownloadPath, + defaultTabValue, downloadablePathForReportType, - exposeSecurityDashboard: JSON.parse(exposeSecurityDashboard), - exposeLicenseScanningData: JSON.parse(exposeLicenseScanningData), + exposeSecurityDashboard: parseBoolean(exposeSecurityDashboard), + exposeLicenseScanningData: parseBoolean(exposeLicenseScanningData), + graphqlResourceEtag, + pipelineIid, + pipelineProjectPath, }, errorCaptured(err, _vm, info) { reportToSentry('pipeline_tabs', `error: ${err}, info: ${info}`); diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index f4d9a44a754..6dccdb1a3e6 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -40,6 +40,8 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { params, ciRunnerSettingsPath, anyRunnersAvailable, + iosRunnersAvailable, + registrationToken, } = el.dataset; return new Vue({ @@ -49,6 +51,9 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { artifactsEndpoint, artifactsEndpointPlaceholder, suggestedCiTemplates: JSON.parse(suggestedCiTemplates), + ciRunnerSettingsPath, + anyRunnersAvailable: parseBoolean(anyRunnersAvailable), + iosRunnersAvailable: parseBoolean(iosRunnersAvailable), }, data() { return { @@ -78,8 +83,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { projectId, defaultBranchName, params: JSON.parse(params), - ciRunnerSettingsPath, - anyRunnersAvailable: parseBoolean(anyRunnersAvailable), + registrationToken, }, }); }, diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js index b7f590a7b3c..f0556f3d12e 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -38,11 +38,7 @@ export const fetchTestSuite = ({ state, commit, dispatch }, index) => { return axios .get(state.suiteEndpoint, { params: { build_ids } }) .then(({ data }) => commit(types.SET_SUITE, { suite: data, index })) - .catch(() => { - createFlash({ - message: s__('TestReports|There was an error fetching the test suite.'), - }); - }) + .catch((error) => commit(types.SET_SUITE_ERROR, error)) .finally(() => { dispatch('toggleLoading'); }); diff --git a/app/assets/javascripts/pipelines/stores/test_reports/constants.js b/app/assets/javascripts/pipelines/stores/test_reports/constants.js new file mode 100644 index 00000000000..8eebfb6b208 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/constants.js @@ -0,0 +1 @@ +export const ARTIFACTS_EXPIRED_ERROR_MESSAGE = 'Test report artifacts have expired'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js index 03680de0fa9..e6a88bb4175 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/getters.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js @@ -1,4 +1,5 @@ import { addIconStatus, formatFilePath, formattedTime } from './utils'; +import { ARTIFACTS_EXPIRED_ERROR_MESSAGE } from './constants'; export const getTestSuites = (state) => { const { test_suites: testSuites = [] } = state.testReports; @@ -29,3 +30,6 @@ export const getSuiteTests = (state) => { }; export const getSuiteTestCount = (state) => getSelectedSuite(state)?.test_cases?.length || 0; + +export const getSuiteArtifactsExpired = (state) => + state.errorMessage === ARTIFACTS_EXPIRED_ERROR_MESSAGE; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js index 803f6bf60b1..7651a2f4327 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js @@ -2,4 +2,5 @@ export const SET_PAGE = 'SET_PAGE'; export const SET_SELECTED_SUITE_INDEX = 'SET_SELECTED_SUITE_INDEX'; export const SET_SUMMARY = 'SET_SUMMARY'; export const SET_SUITE = 'SET_SUITE'; +export const SET_SUITE_ERROR = 'SET_SUITE_ERROR'; export const TOGGLE_LOADING = 'TOGGLE_LOADING'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js index cf0bf8483dd..68ee063dda7 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js @@ -1,3 +1,5 @@ +import createFlash from '~/flash'; +import { s__ } from '~/locale'; import * as types from './mutation_types'; export default { @@ -13,6 +15,18 @@ export default { state.testReports.test_suites[index] = { ...suite, hasFullSuite: true }; }, + [types.SET_SUITE_ERROR](state, error) { + const errorMessage = error.response?.data?.errors; + + if (errorMessage) { + state.errorMessage = errorMessage; + } else { + createFlash({ + message: s__('TestReports|There was an error fetching the test suite.'), + }); + } + }, + [types.SET_SELECTED_SUITE_INDEX](state, selectedSuiteIndex) { Object.assign(state, { selectedSuiteIndex }); }, diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js index 0ee6f53fa58..3ec9418c14e 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/state.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js @@ -5,6 +5,7 @@ export default ({ blobPath = '', summaryEndpoint = '', suiteEndpoint = '' }) => testReports: {}, selectedSuiteIndex: null, isLoading: false, + errorMessage: null, pageInfo: { page: 1, perPage: 20, diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index f6e1c8b7412..588d15495ab 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -1,7 +1,12 @@ import * as Sentry from '@sentry/browser'; import { pickBy } from 'lodash'; -import { SUPPORTED_FILTER_PARAMETERS, NEEDS_PROPERTY } from './constants'; - +import { getParameterValues } from '~/lib/utils/url_utility'; +import { + NEEDS_PROPERTY, + SUPPORTED_FILTER_PARAMETERS, + TAB_QUERY_PARAM, + validPipelineTabNames, +} from './constants'; /* The following functions are the main engine in transforming the data as received from the endpoint into the format the d3 graph expects. @@ -138,3 +143,13 @@ export const reportMessageToSentry = (component, message, context) => { Sentry.captureMessage(message); }); }; + +export const getPipelineDefaultTab = (url) => { + const [tabQueryValue] = getParameterValues(TAB_QUERY_PARAM, url); + + if (tabQueryValue && validPipelineTabNames.includes(tabQueryValue)) { + return tabQueryValue; + } + + return null; +}; diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js index 09dbf2cee04..ad80032c551 100644 --- a/app/assets/javascripts/project_select_combo_button.js +++ b/app/assets/javascripts/project_select_combo_button.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import { sprintf, __ } from '~/locale'; +import { sanitizeUrl } from '~/lib/utils/url_utility'; import AccessorUtilities from './lib/utils/accessor'; import { loadCSSFile } from './lib/utils/css_utils'; @@ -80,7 +81,7 @@ export default class ProjectSelectComboButton { setNewItemBtnAttributes(project) { if (project) { - this.newItemBtn.attr('href', project.url); + this.newItemBtn.attr('href', sanitizeUrl(project.url)); this.newItemBtn.text( sprintf(__('New %{type} in %{project}'), { type: this.resourceLabel, diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue index 8511f9bdb0f..9bd78b7c89e 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue @@ -121,20 +121,18 @@ export default { </script> <template> - <div> + <div class="gl-pt-2"> <gl-loading-icon v-if="$apollo.queries.pipeline.loading" /> - <div v-else> + <div v-else class="gl-align-items-center gl-display-flex"> <linked-pipelines-mini-list v-if="upstreamPipeline" - :triggered-by="[upstreamPipeline]" + :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ + upstreamPipeline, + ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" data-testid="commit-box-mini-graph-upstream" /> - <pipeline-mini-graph - :stages="formattedStages" - class="gl-display-inline" - data-testid="commit-box-mini-graph" - /> + <pipeline-mini-graph :stages="formattedStages" data-testid="commit-box-mini-graph" /> <linked-pipelines-mini-list v-if="hasDownstream" diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 3e1c471f015..2bf13941f6f 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { debounce } from 'lodash'; -import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates'; +import DEFAULT_PROJECT_TEMPLATES from 'any_else_ce/projects/default_project_templates'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '../lib/utils/constants'; import { ENTER_KEY } from '../lib/utils/keys'; @@ -286,9 +286,6 @@ const bindEvents = () => { }); $('.js-import-git-toggle-button').on('click', () => { - const $projectMirror = $('#project_mirror'); - - $projectMirror.attr('disabled', !$projectMirror.attr('disabled')); setProjectNamePathHandlers( $('.tab-pane.active #project_name'), $('.tab-pane.active #project_path'), diff --git a/app/assets/javascripts/projects/settings/init_access_dropdown.js b/app/assets/javascripts/projects/settings/init_access_dropdown.js index 11272652b63..941efaef3bc 100644 --- a/app/assets/javascripts/projects/settings/init_access_dropdown.js +++ b/app/assets/javascripts/projects/settings/init_access_dropdown.js @@ -4,7 +4,7 @@ import AccessDropdown from './components/access_dropdown.vue'; export const initAccessDropdown = (el, options) => { if (!el) { - return false; + return null; } const { accessLevelsData, accessLevel } = options; diff --git a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue index d4c97cbf038..9c8de9bef2d 100644 --- a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue +++ b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue @@ -85,7 +85,7 @@ export default { <gl-avatar-labeled :src="dropdownItem.avatarUrl" :entity-name="dropdownItem.name" - :label="dropdownItem.name" + :label="dropdownItem.title" :size="32" :shape="$options.AVATAR_SHAPE_OPTION_RECT" /> diff --git a/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql b/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql index b193165062a..0c0a874d950 100644 --- a/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql +++ b/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql @@ -3,6 +3,7 @@ query searchProjectTopics($search: String) { nodes { id name + title avatarUrl } } diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index e5ddfe82e3b..8a9a0b541f3 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -128,7 +128,7 @@ export default { this.selectedTemplate = selectedTemplate; }, validateProjectKey() { - if (this.projectKey && !new RegExp(/^[a-z0-9_]+$/).test(this.projectKey)) { + if (this.projectKey && !/^[a-z0-9_]+$/.test(this.projectKey)) { this.projectKeyError = __('Only use lowercase letters, numbers, and underscores.'); return; } @@ -270,18 +270,16 @@ export default { </template> </gl-form-group> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button - variant="success" - class="gl-mt-5" - data-testid="save_service_desk_settings_button" - data-qa-selector="save_service_desk_settings_button" - :disabled="isTemplateSaving" - @click="onSaveTemplate" - > - {{ __('Save changes') }} - </gl-button> - </div> + <gl-button + variant="confirm" + class="gl-mt-5" + data-testid="save_service_desk_settings_button" + data-qa-selector="save_service_desk_settings_button" + :disabled="isTemplateSaving" + @click="onSaveTemplate" + > + {{ __('Save changes') }} + </gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue deleted file mode 100644 index befbca48736..00000000000 --- a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue +++ /dev/null @@ -1,149 +0,0 @@ -<script> -import { - GlButton, - GlFormGroup, - GlFormInput, - GlModal, - GlModalDirective, - GlSprintf, - GlLink, -} from '@gitlab/ui'; -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; - -export default { - copyToClipboard: __('Copy'), - components: { - GlButton, - GlFormGroup, - GlFormInput, - GlModal, - ClipboardButton, - GlSprintf, - GlLink, - }, - directives: { - 'gl-modal': GlModalDirective, - }, - props: { - initialAuthorizationKey: { - type: String, - required: false, - default: '', - }, - changeKeyUrl: { - type: String, - required: true, - }, - notifyUrl: { - type: String, - required: true, - }, - learnMoreUrl: { - type: String, - required: true, - }, - disabled: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - authorizationKey: this.initialAuthorizationKey, - }; - }, - methods: { - resetKey() { - axios - .post(this.changeKeyUrl) - .then((res) => { - this.authorizationKey = res.data.token; - }) - .catch(() => { - createFlash({ - message: __('Failed to reset key. Please try again.'), - }); - }); - }, - }, -}; -</script> - -<template> - <div class="row py-4 border-top js-prometheus-alerts"> - <div class="col-lg-3"> - <h4 class="mt-0"> - {{ __('Alerts') }} - </h4> - <p> - {{ __('Receive alerts from manually configured Prometheus servers.') }} - </p> - </div> - <div class="col-lg-9"> - <gl-sprintf - :message=" - __( - 'To receive alerts from manually configured Prometheus services, add the following URL and Authorization key to your Prometheus webhook config file. Learn more about %{linkStart}configuring Prometheus%{linkEnd} to send alerts to GitLab.', - ) - " - > - <template #link="{ content }"> - <gl-link :href="learnMoreUrl" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - <gl-form-group :label="__('URL')" label-for="notify-url" label-class="label-bold"> - <div class="input-group"> - <gl-form-input id="notify-url" :readonly="true" :value="notifyUrl" /> - <span class="input-group-append"> - <clipboard-button - :text="notifyUrl" - :title="$options.copyToClipboard" - :disabled="disabled" - /> - </span> - </div> - </gl-form-group> - <gl-form-group - :label="__('Authorization key')" - label-for="authorization-key" - label-class="label-bold" - > - <div class="input-group"> - <gl-form-input id="authorization-key" :readonly="true" :value="authorizationKey" /> - <span class="input-group-append"> - <clipboard-button - :text="authorizationKey" - :title="$options.copyToClipboard" - :disabled="disabled" - /> - </span> - </div> - </gl-form-group> - <template v-if="authorizationKey.length > 0"> - <gl-modal - modal-id="authKeyModal" - :title="__('Reset authorization key?')" - :ok-title="__('Reset authorization key')" - ok-variant="danger" - @ok="resetKey" - > - {{ - __( - 'Resetting the authorization key will invalidate the previous key. Existing alert configurations will need to be updated with the new key.', - ) - }} - </gl-modal> - <gl-button v-gl-modal.authKeyModal class="js-reset-auth-key" :disabled="disabled">{{ - __('Reset key') - }}</gl-button> - </template> - <gl-button v-else :disabled="disabled" class="js-reset-auth-key" @click="resetKey">{{ - __('Generate key') - }}</gl-button> - </div> - </div> -</template> diff --git a/app/assets/javascripts/prometheus_alerts/index.js b/app/assets/javascripts/prometheus_alerts/index.js deleted file mode 100644 index 7efe6ed186b..00000000000 --- a/app/assets/javascripts/prometheus_alerts/index.js +++ /dev/null @@ -1,28 +0,0 @@ -import Vue from 'vue'; -import ResetKey from './components/reset_key.vue'; - -export default () => { - const el = document.querySelector('#js-settings-prometheus-alerts'); - - if (!el) { - return; - } - - const { authorizationKey, changeKeyUrl, notifyUrl, learnMoreUrl, disabled } = el.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el, - render(createElement) { - return createElement(ResetKey, { - props: { - initialAuthorizationKey: authorizationKey, - changeKeyUrl, - notifyUrl, - learnMoreUrl, - disabled, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue index 9ee2e7a4ffd..42de419aec4 100644 --- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -195,7 +195,10 @@ export default { :path-id-separator="pathIdSeparator" :input-value="inputValue" :auto-complete-sources="transformedAutocompleteSources" - :auto-complete-options="{ issues: autoCompleteIssues, epics: autoCompleteEpics }" + :auto-complete-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + issues: autoCompleteIssues, + epics: autoCompleteEpics, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" :issuable-type="issuableType" @pendingIssuableRemoveRequest="onPendingIssuableRemoveRequest" @formCancel="onFormCancel" diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue index 40d58c04753..da049d68467 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_root.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue @@ -220,7 +220,8 @@ export default { const startsWithNumber = String(touchedReference).match(/^[0-9]/) !== null; if (startsWithNumber) { - this.inputValue = `#${touchedReference}`; + const { pathIdSeparator } = this; + this.inputValue = `${pathIdSeparator}${touchedReference}`; } else { this.inputValue = `${touchedReference}`; } diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 8365e6a5ab0..327da1fb2a1 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlFormInput, GlFormGroup, GlSprintf } from '@gitlab/ui'; +import { GlButton, GlFormCheckbox, GlFormInput, GlFormGroup, GlLink, GlSprintf } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import { isSameOriginUrl, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -12,9 +12,11 @@ import TagField from './tag_field.vue'; export default { name: 'ReleaseEditNewApp', components: { + GlFormCheckbox, GlFormInput, GlFormGroup, GlButton, + GlLink, GlSprintf, MarkdownField, AssetLinksForm, @@ -28,6 +30,7 @@ export default { 'fetchError', 'markdownDocsPath', 'markdownPreviewPath', + 'editReleaseDocsPath', 'releasesPagePath', 'release', 'newMilestonePath', @@ -35,8 +38,9 @@ export default { 'projectId', 'groupId', 'groupMilestonesAvailable', + 'tagNotes', ]), - ...mapGetters('editNew', ['isValid', 'isExistingRelease']), + ...mapGetters('editNew', ['isValid', 'isExistingRelease', 'formattedReleaseNotes']), showForm() { return Boolean(!this.isFetchingRelease && !this.fetchError && this.release); }, @@ -64,6 +68,14 @@ export default { this.updateReleaseMilestones(milestones); }, }, + includeTagNotes: { + get() { + return this.$store.state.editNew.includeTagNotes; + }, + set(includeTagNotes) { + this.updateIncludeTagNotes(includeTagNotes); + }, + }, cancelPath() { const backUrl = getParameterByName(BACK_URL_PARAM); @@ -105,6 +117,7 @@ export default { 'updateReleaseTitle', 'updateReleaseNotes', 'updateReleaseMilestones', + 'updateIncludeTagNotes', ]), submitForm() { if (!this.isFormSubmissionDisabled) { @@ -161,7 +174,7 @@ export default { :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :add-spacing-classes="false" - :textarea-value="releaseNotes" + :textarea-value="formattedReleaseNotes" class="gl-mt-3 gl-mb-3" > <template #textarea> @@ -178,6 +191,25 @@ export default { </markdown-field> </div> </gl-form-group> + <gl-form-group v-if="!isExistingRelease"> + <gl-form-checkbox v-model="includeTagNotes"> + {{ s__('Release|Include message from the annotated tag.') }} + + <template #help> + <gl-sprintf + :message=" + s__( + 'Release|You can edit the content later by editing the release. %{linkStart}How do I edit a release?%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="editReleaseDocsPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-form-checkbox> + </gl-form-group> <asset-links-form /> diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue index 9e05d00a98d..d3b6d07590f 100644 --- a/app/assets/javascripts/releases/components/tag_field_new.vue +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -52,7 +52,10 @@ export default { }, }, showTagNameValidationError() { - return this.isInputDirty && this.validationErrors.isTagNameEmpty; + return ( + this.isInputDirty && + (this.validationErrors.isTagNameEmpty || this.validationErrors.existingRelease) + ); }, tagNameInputId() { return uniqueId('tag-name-input-'); @@ -60,9 +63,14 @@ export default { createFromSelectorId() { return uniqueId('create-from-selector-'); }, + tagFeedback() { + return this.validationErrors.existingRelease + ? __('Selected tag is already in use. Choose another option.') + : __('Tag name is required.'); + }, }, methods: { - ...mapActions('editNew', ['updateReleaseTagName', 'updateCreateFrom']), + ...mapActions('editNew', ['updateReleaseTagName', 'updateCreateFrom', 'fetchTagNotes']), markInputAsDirty() { this.isInputDirty = true; }, @@ -112,7 +120,7 @@ export default { <gl-form-group data-testid="tag-name-field" :state="!showTagNameValidationError" - :invalid-feedback="__('Tag name is required')" + :invalid-feedback="tagFeedback" :label="$options.translations.tagName.label" :label-for="tagNameInputId" :label-description="$options.translations.tagName.labelDescription" @@ -125,6 +133,7 @@ export default { :translations="$options.translations.tagName" :enabled-ref-types="$options.tagNameEnabledRefTypes" :state="!showTagNameValidationError" + @input="fetchTagNotes" @hide.once="markInputAsDirty" > <template #footer="{ isLoading, matches, query }"> diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js index b3ba4f9263a..08197377f61 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -1,3 +1,4 @@ +import { getTag } from '~/rest_api'; import createFlash from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -7,6 +8,7 @@ import deleteReleaseAssetLinkMutation from '~/releases/graphql/mutations/delete_ import updateReleaseMutation from '~/releases/graphql/mutations/update_release.mutation.graphql'; import oneReleaseForEditingQuery from '~/releases/graphql/queries/one_release_for_editing.query.graphql'; import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util'; + import * as types from './mutation_types'; export const initializeRelease = ({ commit, dispatch, getters }) => { @@ -224,3 +226,23 @@ export const updateRelease = async ({ commit, dispatch, state, getters }) => { }); } }; + +export const fetchTagNotes = ({ commit, state }, tagName) => { + commit(types.REQUEST_TAG_NOTES); + + return getTag(state.projectId, tagName) + .then(({ data }) => { + commit(types.RECEIVE_TAG_NOTES_SUCCESS, data); + }) + .catch((error) => { + createFlash({ + message: s__('Release|Unable to fetch the tag notes.'), + }); + + commit(types.RECEIVE_TAG_NOTES_ERROR, error); + }); +}; + +export const updateIncludeTagNotes = ({ commit }, includeTagNotes) => { + commit(types.UPDATE_INCLUDE_TAG_NOTES, includeTagNotes); +}; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js index d4f49e53619..0ca5eb9931a 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js @@ -1,4 +1,5 @@ import { isEmpty } from 'lodash'; +import { s__ } from '~/locale'; import { hasContent } from '~/lib/utils/text_utility'; import { getDuplicateItemsFromArray } from '~/lib/utils/array_utility'; @@ -52,6 +53,10 @@ export const validationErrors = (state) => { errors.isTagNameEmpty = true; } + if (state.existingRelease) { + errors.existingRelease = true; + } + // Each key of this object is a URL, and the value is an // array of Release link objects that share this URL. // This is used for detecting duplicate URLs. @@ -113,11 +118,15 @@ export const validationErrors = (state) => { /** Returns whether or not the release object is valid */ export const isValid = (_state, getters) => { const errors = getters.validationErrors; - return Object.values(errors.assets.links).every(isEmpty) && !errors.isTagNameEmpty; + return ( + Object.values(errors.assets.links).every(isEmpty) && + !errors.isTagNameEmpty && + !errors.existingRelease + ); }; /** Returns all the variables for a `releaseUpdate` GraphQL mutation */ -export const releaseUpdateMutatationVariables = (state) => { +export const releaseUpdateMutatationVariables = (state, getters) => { const name = state.release.name?.trim().length > 0 ? state.release.name.trim() : null; // Milestones may be either a list of milestone objects OR just a list @@ -129,7 +138,9 @@ export const releaseUpdateMutatationVariables = (state) => { projectPath: state.projectPath, tagName: state.release.tagName, name, - description: state.release.description, + description: state.includeTagNotes + ? getters.formattedReleaseNotes + : state.release.description, milestones, }, }; @@ -151,3 +162,8 @@ export const releaseCreateMutatationVariables = (state, getters) => { }, }; }; + +export const formattedReleaseNotes = ({ includeTagNotes, release: { description }, tagNotes }) => + includeTagNotes && tagNotes + ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${tagNotes}\n` + : description; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js index 1b2f5f33f02..daa077309a1 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js @@ -20,3 +20,9 @@ export const UPDATE_ASSET_LINK_URL = 'UPDATE_ASSET_LINK_URL'; export const UPDATE_ASSET_LINK_NAME = 'UPDATE_ASSET_LINK_NAME'; export const UPDATE_ASSET_LINK_TYPE = 'UPDATE_ASSET_LINK_TYPE'; export const REMOVE_ASSET_LINK = 'REMOVE_ASSET_LINK'; + +export const REQUEST_TAG_NOTES = 'REQUEST_TAG_NOTES'; +export const RECEIVE_TAG_NOTES_SUCCESS = 'RECEIVE_TAG_NOTES_SUCCESS'; +export const RECEIVE_TAG_NOTES_ERROR = 'RECEIVE_TAG_NOTES_ERROR'; + +export const UPDATE_INCLUDE_TAG_NOTES = 'UPDATE_INCLUDE_TAG_NOTES'; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js index cf282f9ab2c..6b22468bbfe 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js @@ -95,4 +95,22 @@ export default { [types.REMOVE_ASSET_LINK](state, linkIdToRemove) { state.release.assets.links = state.release.assets.links.filter((l) => l.id !== linkIdToRemove); }, + + [types.REQUEST_TAG_NOTES](state) { + state.isFetchingTagNotes = true; + }, + [types.RECEIVE_TAG_NOTES_SUCCESS](state, data) { + state.fetchError = undefined; + state.isFetchingTagNotes = false; + state.tagNotes = data.message; + state.existingRelease = data.release; + }, + [types.RECEIVE_TAG_NOTES_ERROR](state, error) { + state.fetchError = error; + state.isFetchingTagNotes = false; + state.tagNotes = ''; + }, + [types.UPDATE_INCLUDE_TAG_NOTES](state, includeTagNotes) { + state.includeTagNotes = includeTagNotes; + }, }; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js index 315d07ac664..33cb3ee06d0 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js @@ -9,6 +9,7 @@ export default ({ manageMilestonesPath, newMilestonePath, releasesPagePath, + editReleaseDocsPath, tagName = null, defaultBranch = null, @@ -23,6 +24,7 @@ export default ({ manageMilestonesPath, newMilestonePath, releasesPagePath, + editReleaseDocsPath, /** * The name of the tag associated with the release, provided by the backend. @@ -48,4 +50,8 @@ export default ({ isUpdatingRelease: false, updateError: null, + + tagNotes: '', + includeTagNotes: false, + existingRelease: null, }); diff --git a/app/assets/javascripts/reports/codequality_report/store/getters.js b/app/assets/javascripts/reports/codequality_report/store/getters.js index 4712f8cbefe..70d11e96a54 100644 --- a/app/assets/javascripts/reports/codequality_report/store/getters.js +++ b/app/assets/javascripts/reports/codequality_report/store/getters.js @@ -1,5 +1,5 @@ import { spriteIcon } from '~/lib/utils/common_utils'; -import { sprintf, s__ } from '~/locale'; +import { sprintf, s__, n__ } from '~/locale'; import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '../../constants'; export const hasCodequalityIssues = (state) => @@ -29,9 +29,17 @@ export const codequalityText = (state) => { }, ); } else if (resolvedIssues.length) { - text = s__(`ciReport|Code quality improved`); + text = n__( + `ciReport|Code quality improved due to 1 resolved issue`, + `ciReport|Code quality improved due to %d resolved issues`, + resolvedIssues.length, + ); } else if (newIssues.length) { - text = s__(`ciReport|Code quality degraded`); + text = n__( + `ciReport|Code quality degraded due to 1 new issue`, + `ciReport|Code quality degraded due to %d new issues`, + newIssues.length, + ); } return text; diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index c9e4aab1db1..3729bd4c601 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -8,7 +8,7 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import { redirectTo } from '~/lib/utils/url_utility'; +import { redirectTo, getLocationHash } from '~/lib/utils/url_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; import CodeIntelligence from '~/code_navigation/components/app.vue'; @@ -17,15 +17,12 @@ import getRefMixin from '../mixins/get_ref'; import blobInfoQuery from '../queries/blob_info.query.graphql'; import userInfoQuery from '../queries/user_info.query.graphql'; import applicationInfoQuery from '../queries/application_info.query.graphql'; -import { DEFAULT_BLOB_INFO, TEXT_FILE_TYPE, LFS_STORAGE } from '../constants'; +import { DEFAULT_BLOB_INFO, TEXT_FILE_TYPE, LFS_STORAGE, LEGACY_FILE_TYPES } from '../constants'; import BlobButtonGroup from './blob_button_group.vue'; import ForkSuggestion from './fork_suggestion.vue'; import { loadViewer } from './blob_viewers'; export default { - i18n: { - pipelineEditor: __('Pipeline Editor'), - }, components: { BlobHeader, BlobButtonGroup, @@ -132,7 +129,8 @@ export default { return this.shouldLoadLegacyViewer ? null : loadViewer(fileType, this.isUsingLfs); }, shouldLoadLegacyViewer() { - return this.viewer.fileType === TEXT_FILE_TYPE && !this.glFeatures.highlightJs; + const isTextFile = this.viewer.fileType === TEXT_FILE_TYPE && !this.glFeatures.highlightJs; + return isTextFile || LEGACY_FILE_TYPES.includes(this.blobInfo.fileType); }, legacyViewerLoaded() { return ( @@ -199,11 +197,20 @@ export default { this.legacyRichViewer = html; } + this.scrollToHash(); this.isBinary = binary; this.isLoadingLegacyViewer = false; }) .catch(() => this.displayError()); }, + scrollToHash() { + const hash = getLocationHash(); + if (hash) { + // Ensures the browser's native scroll to hash is triggered for async content + window.location.hash = ''; + window.location.hash = hash; + } + }, displayError() { createFlash({ message: __('An error occurred while loading the file. Please try again.') }); }, diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 84c9f9d0bbe..20888db80a9 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -269,6 +269,9 @@ export default { renderAddToTreeDropdown() { return !this.isBlobPath && (this.canCollaborate || this.canCreateMrFromFork); }, + newDirectoryPath() { + return joinPaths(this.newDirPath, this.currentPath); + }, }, methods: { isLast(i) { @@ -332,7 +335,7 @@ export default { :commit-message="__('Add new directory')" :target-branch="selectedBranch" :original-branch="originalBranch" - :path="newDirPath" + :path="newDirectoryPath" /> </nav> </template> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 2810db33e64..03dd7c6fada 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -14,6 +14,7 @@ import CiIcon from '~/vue_shared/components/ci_icon.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; @@ -27,6 +28,7 @@ export default { GlButtonGroup, GlLink, GlLoadingIcon, + UserAvatarImage, }, directives: { GlTooltip: GlTooltipDirective, @@ -111,24 +113,24 @@ export default { </script> <template> - <div class="well-segment commit gl-p-5 gl-w-full"> + <div class="well-segment commit gl-p-5 gl-w-full gl-display-flex"> <gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" /> <template v-else-if="commit"> <user-avatar-link v-if="commit.author" :link-href="commit.author.webPath" :img-src="commit.author.avatarUrl" - :img-size="40" - class="avatar-cell" + :img-size="32" + :img-css-classes="'gl-mr-0!' /* NOTE: this is needed only while we migrate user-avatar-image to GlAvatar (7731 epics) */" + class="gl-my-2 gl-mr-4" + /> + <user-avatar-image + v-else + class="gl-my-2 gl-mr-4" + :img-src="commit.authorGravatar || $options.defaultAvatarUrl" + :css-classes="'gl-mr-0!' /* NOTE: this is needed only while we migrate user-avatar-image to GlAvatar (7731 epics) */" + :size="32" /> - <span v-else class="avatar-cell user-avatar-link"> - <img - :src="commit.authorGravatar || $options.defaultAvatarUrl" - width="40" - height="40" - class="avatar s40" - /> - </span> <div class="commit-detail flex-list"> <div class="commit-content qa-commit-content"> <gl-link @@ -168,7 +170,10 @@ export default { class="commit-row-description gl-mb-3" ></pre> </div> - <div class="commit-actions flex-row"> + <div class="gl-flex-grow-1"></div> + <div + class="commit-actions gl-display-flex gl-flex-align gl-align-items-center gl-flex-direction-row" + > <div v-if="commit.signatureHtml" v-html="commit.signatureHtml /* eslint-disable-line vue/no-v-html */" diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index bb9d3180be8..2cafeed2ef4 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -86,3 +86,24 @@ export const DEFAULT_BLOB_INFO = { export const TEXT_FILE_TYPE = 'text'; export const LFS_STORAGE = 'lfs'; + +/** + * We have some features (like linking to external dependencies) that our frontend highlighter + * do not yet support. + * These are file types that we want the legacy (backend) syntax highlighter to highlight. + */ +export const LEGACY_FILE_TYPES = [ + 'package_json', + 'gemfile', + 'gemspec', + 'composer_json', + 'podfile', + 'podspec', + 'podspec_json', + 'cartfile', + 'godeps_json', + 'requirements_txt', + 'cargo_toml', + 'go_mod', + 'go_sum', +]; diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index b38a1cfdc7b..8f8735a6371 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -151,12 +151,20 @@ export default function setupVueRepositoryList() { const treeHistoryLinkEl = document.getElementById('js-tree-history-link'); const { historyLink } = treeHistoryLinkEl.dataset; + let { isProjectOverview } = treeHistoryLinkEl.dataset; + + const isProjectOverviewAfterEach = router.afterEach(() => { + isProjectOverview = false; + isProjectOverviewAfterEach(); + }); // eslint-disable-next-line no-new new Vue({ el: treeHistoryLinkEl, router, render(h) { + if (parseBoolean(isProjectOverview) && !this.$route.params.path) return null; + return h( GlButton, { diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js index 48a15954035..0f8e6945bf9 100644 --- a/app/assets/javascripts/rest_api.js +++ b/app/assets/javascripts/rest_api.js @@ -4,6 +4,7 @@ export * from './api/user_api'; export * from './api/markdown_api'; export * from './api/bulk_imports_api'; export * from './api/namespaces_api'; +export * from './api/tags_api'; // Note: It's not possible to spy on methods imported from this file in // Jest tests. diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 009afe03ea6..a3abc8b8e90 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -7,6 +7,18 @@ import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { sprintf, s__, __ } from './locale'; +const updateSidebarClasses = (layoutPage, rightSidebar) => { + if (window.innerWidth >= 768) { + layoutPage.classList.remove('right-sidebar-expanded', 'right-sidebar-collapsed'); + rightSidebar.classList.remove('right-sidebar-collapsed'); + rightSidebar.classList.add('right-sidebar-expanded'); + } else { + layoutPage.classList.add('right-sidebar-collapsed', 'is-merge-request'); + rightSidebar.classList.add('right-sidebar-collapsed'); + rightSidebar.classList.remove('right-sidebar-expanded'); + } +}; + function Sidebar() { this.toggleTodo = this.toggleTodo.bind(this); this.sidebar = $('aside'); @@ -42,13 +54,22 @@ Sidebar.prototype.addEventListeners = function () { this.sidebar.on('hiddenGlDropdown', this, this.onSidebarDropdownHidden); $document.on('click', '.js-sidebar-toggle', this.sidebarToggleClicked); - return $(document) - .off('click', '.js-issuable-todo') - .on('click', '.js-issuable-todo', this.toggleTodo); + $(document).off('click', '.js-issuable-todo').on('click', '.js-issuable-todo', this.toggleTodo); + + if (window.gon?.features?.movedMrSidebar) { + const layoutPage = document.querySelector('.layout-page'); + const rightSidebar = document.querySelector('.js-right-sidebar'); + + updateSidebarClasses(layoutPage, rightSidebar); + window.addEventListener('resize', () => updateSidebarClasses(layoutPage, rightSidebar)); + } }; Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { const $this = $(this); + + if ($this.hasClass('right-sidebar-merge-requests')) return; + const $collapseIcon = $('.js-sidebar-collapse'); const $expandIcon = $('.js-sidebar-expand'); const $toggleContainer = $('.js-sidebar-toggle-container'); diff --git a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue b/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue index c2db3b9facd..40787cf72da 100644 --- a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue +++ b/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue @@ -5,7 +5,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '../components/runner_header.vue'; import RunnerUpdateForm from '../components/runner_update_form.vue'; import { I18N_FETCH_ERROR } from '../constants'; -import runnerQuery from '../graphql/details/runner.query.graphql'; +import runnerFormQuery from '../graphql/edit/runner_form.query.graphql'; import { captureException } from '../sentry_utils'; export default { @@ -19,6 +19,11 @@ export default { type: String, required: true, }, + runnerPath: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -27,7 +32,7 @@ export default { }, apollo: { runner: { - query: runnerQuery, + query: runnerFormQuery, variables() { return { id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), @@ -40,6 +45,11 @@ export default { }, }, }, + computed: { + loading() { + return this.$apollo.queries.runner.loading; + }, + }, errorCaptured(error) { this.reportToSentry(error); }, @@ -53,6 +63,11 @@ export default { <template> <div> <runner-header v-if="runner" :runner="runner" /> - <runner-update-form :runner="runner" class="gl-my-5" /> + <runner-update-form + :loading="loading" + :runner="runner" + :runner-path="runnerPath" + class="gl-my-5" + /> </div> </template> diff --git a/app/assets/javascripts/runner/admin_runner_edit/index.js b/app/assets/javascripts/runner/admin_runner_edit/index.js index adb420f9963..a2ac5731a62 100644 --- a/app/assets/javascripts/runner/admin_runner_edit/index.js +++ b/app/assets/javascripts/runner/admin_runner_edit/index.js @@ -12,7 +12,7 @@ export const initAdminRunnerEdit = (selector = '#js-admin-runner-edit') => { return null; } - const { runnerId } = el.dataset; + const { runnerId, runnerPath } = el.dataset; const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), @@ -25,6 +25,7 @@ export const initAdminRunnerEdit = (selector = '#js-admin-runner-edit') => { return h(AdminRunnerEditApp, { props: { runnerId, + runnerPath, }, }); }, diff --git a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue index 86ad912f017..c3f317b40b0 100644 --- a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue +++ b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue @@ -1,19 +1,23 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; -import { createAlert } from '~/flash'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { redirectTo } from '~/lib/utils/url_utility'; +import RunnerDeleteButton from '../components/runner_delete_button.vue'; import RunnerEditButton from '../components/runner_edit_button.vue'; import RunnerPauseButton from '../components/runner_pause_button.vue'; import RunnerHeader from '../components/runner_header.vue'; import RunnerDetails from '../components/runner_details.vue'; import { I18N_FETCH_ERROR } from '../constants'; -import runnerQuery from '../graphql/details/runner.query.graphql'; +import runnerQuery from '../graphql/show/runner.query.graphql'; import { captureException } from '../sentry_utils'; +import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; export default { name: 'AdminRunnerShowApp', components: { + RunnerDeleteButton, RunnerEditButton, RunnerPauseButton, RunnerHeader, @@ -27,6 +31,10 @@ export default { type: String, required: true, }, + runnersPath: { + type: String, + required: true, + }, }, data() { return { @@ -52,6 +60,9 @@ export default { canUpdate() { return this.runner.userPermissions?.updateRunner; }, + canDelete() { + return this.runner.userPermissions?.deleteRunner; + }, }, errorCaptured(error) { this.reportToSentry(error); @@ -60,6 +71,10 @@ export default { reportToSentry(error) { captureException({ error, component: this.$options.name }); }, + onDeleted({ message }) { + saveAlertToLocalStorage({ message, variant: VARIANT_SUCCESS }); + redirectTo(this.runnersPath); + }, }, }; </script> @@ -69,6 +84,7 @@ export default { <template #actions> <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" /> <runner-pause-button v-if="canUpdate" :runner="runner" /> + <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" /> </template> </runner-header> diff --git a/app/assets/javascripts/runner/admin_runner_show/index.js b/app/assets/javascripts/runner/admin_runner_show/index.js index a781898cf8d..ea455416648 100644 --- a/app/assets/javascripts/runner/admin_runner_show/index.js +++ b/app/assets/javascripts/runner/admin_runner_show/index.js @@ -1,18 +1,21 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; import AdminRunnerShowApp from './admin_runner_show_app.vue'; Vue.use(VueApollo); export const initAdminRunnerShow = (selector = '#js-admin-runner-show') => { + showAlertFromLocalStorage(); + const el = document.querySelector(selector); if (!el) { return null; } - const { runnerId } = el.dataset; + const { runnerId, runnersPath } = el.dataset; const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), @@ -25,6 +28,7 @@ export const initAdminRunnerShow = (selector = '#js-admin-runner-show') => { return h(AdminRunnerShowApp, { props: { runnerId, + runnersPath, }, }); }, diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index accc9926a57..c2bb635e056 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -38,7 +38,7 @@ import { } from '../runner_search_utils'; import { captureException } from '../sentry_utils'; -const runnersCountSmartQuery = { +const countSmartQuery = () => ({ query: runnersAdminCountQuery, fetchPolicy: fetchPolicies.NETWORK_ONLY, update(data) { @@ -47,6 +47,39 @@ const runnersCountSmartQuery = { error(error) { this.reportToSentry(error); }, +}); + +const tabCountSmartQuery = ({ type }) => { + return { + ...countSmartQuery(), + variables() { + return { + ...this.countVariables, + type, + }; + }, + }; +}; + +const statusCountSmartQuery = ({ status, name }) => { + return { + ...countSmartQuery(), + skip() { + // skip if filtering by status and not using _this_ status as filter + if (this.countVariables.status && this.countVariables.status !== status) { + // reset count for given status + this[name] = null; + return true; + } + return false; + }, + variables() { + return { + ...this.countVariables, + status, + }; + }, + }; }; export default { @@ -101,65 +134,30 @@ export default { this.reportToSentry(error); }, }, + + // Tabs counts allRunnersCount: { - ...runnersCountSmartQuery, - variables() { - return { - ...this.countVariables, - type: null, - }; - }, + ...tabCountSmartQuery({ type: null }), }, instanceRunnersCount: { - ...runnersCountSmartQuery, - variables() { - return { - ...this.countVariables, - type: INSTANCE_TYPE, - }; - }, + ...tabCountSmartQuery({ type: INSTANCE_TYPE }), }, groupRunnersCount: { - ...runnersCountSmartQuery, - variables() { - return { - ...this.countVariables, - type: GROUP_TYPE, - }; - }, + ...tabCountSmartQuery({ type: GROUP_TYPE }), }, projectRunnersCount: { - ...runnersCountSmartQuery, - variables() { - return { - ...this.countVariables, - type: PROJECT_TYPE, - }; - }, + ...tabCountSmartQuery({ type: PROJECT_TYPE }), }, + + // Runner stats onlineRunnersTotal: { - ...runnersCountSmartQuery, - variables() { - return { - status: STATUS_ONLINE, - }; - }, + ...statusCountSmartQuery({ status: STATUS_ONLINE, name: 'onlineRunnersTotal' }), }, offlineRunnersTotal: { - ...runnersCountSmartQuery, - variables() { - return { - status: STATUS_OFFLINE, - }; - }, + ...statusCountSmartQuery({ status: STATUS_OFFLINE, name: 'offlineRunnersTotal' }), }, staleRunnersTotal: { - ...runnersCountSmartQuery, - variables() { - return { - status: STATUS_STALE, - }; - }, + ...statusCountSmartQuery({ status: STATUS_STALE, name: 'staleRunnersTotal' }), }, }, computed: { @@ -263,12 +261,6 @@ export default { </script> <template> <div> - <runner-stats - :online-runners-count="onlineRunnersTotal" - :offline-runners-count="offlineRunnersTotal" - :stale-runners-count="staleRunnersTotal" - /> - <div class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" > @@ -300,6 +292,12 @@ export default { :namespace="$options.filteredSearchNamespace" /> + <runner-stats + :online-runners-count="onlineRunnersTotal" + :offline-runners-count="offlineRunnersTotal" + :stale-runners-count="staleRunnersTotal" + /> + <div v-if="noRunnersFound" class="gl-text-center gl-p-5"> {{ __('No runners found') }} </div> diff --git a/app/assets/javascripts/runner/admin_runners/index.js b/app/assets/javascripts/runner/admin_runners/index.js index 12e2cb2ee9f..b1d8442bb32 100644 --- a/app/assets/javascripts/runner/admin_runners/index.js +++ b/app/assets/javascripts/runner/admin_runners/index.js @@ -5,12 +5,15 @@ import { visitUrl } from '~/lib/utils/url_utility'; import { updateOutdatedUrl } from '~/runner/runner_search_utils'; import createDefaultClient from '~/lib/graphql'; import { createLocalState } from '../graphql/list/local_state'; +import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; import AdminRunnersApp from './admin_runners_app.vue'; Vue.use(GlToast); Vue.use(VueApollo); export const initAdminRunners = (selector = '#js-admin-runners') => { + showAlertFromLocalStorage(); + const el = document.querySelector(selector); if (!el) { diff --git a/app/assets/javascripts/runner/components/registration/registration_dropdown.vue b/app/assets/javascripts/runner/components/registration/registration_dropdown.vue index 3fbe3c1be74..bb2a8ddf151 100644 --- a/app/assets/javascripts/runner/components/registration/registration_dropdown.vue +++ b/app/assets/javascripts/runner/components/registration/registration_dropdown.vue @@ -96,7 +96,7 @@ export default { <runner-instructions-modal v-if="instructionsModalOpened" ref="runnerInstructionsModal" - :registration-token="registrationToken" + :registration-token="currentRegistrationToken" data-testid="runner-instructions-modal" /> </gl-dropdown-item> diff --git a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue index 1234054c660..09d46ce3e66 100644 --- a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue +++ b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue @@ -115,14 +115,14 @@ export default { <gl-modal size="sm" :modal-id="$options.modalId" - :action-primary="{ + :action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { text: $options.i18n.modalAction, attributes: [{ variant: 'danger' }], - }" - :action-secondary="{ + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :action-secondary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { text: $options.i18n.modalCancel, attributes: [{ variant: 'default' }], - }" + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" :title="$options.i18n.modalTitle" @primary="handleModalPrimary" > diff --git a/app/assets/javascripts/runner/components/runner_delete_button.vue b/app/assets/javascripts/runner/components/runner_delete_button.vue index b58665ecbc9..62382891df0 100644 --- a/app/assets/javascripts/runner/components/runner_delete_button.vue +++ b/app/assets/javascripts/runner/components/runner_delete_button.vue @@ -165,6 +165,8 @@ export default { :loading="deleting" :disabled="disabled" variant="danger" + category="secondary" + v-bind="$attrs" > {{ buttonContent }} </gl-button> diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue index b6a5ffc7a64..3734f436034 100644 --- a/app/assets/javascripts/runner/components/runner_details.vue +++ b/app/assets/javascripts/runner/components/runner_details.vue @@ -84,6 +84,9 @@ export default { </runner-detail> <runner-detail :label="s__('Runners|Version')" :value="runner.version" /> <runner-detail :label="s__('Runners|IP Address')" :value="runner.ipAddress" /> + <runner-detail :label="s__('Runners|Executor')" :value="runner.executorName" /> + <runner-detail :label="s__('Runners|Architecture')" :value="runner.architectureName" /> + <runner-detail :label="s__('Runners|Platform')" :value="runner.platformName" /> <runner-detail :label="s__('Runners|Configuration')"> <template #value> <gl-intersperse v-if="configTextProtected || configTextUntagged"> diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue index b25d92d049e..4eb1312b204 100644 --- a/app/assets/javascripts/runner/components/runner_jobs.vue +++ b/app/assets/javascripts/runner/components/runner_jobs.vue @@ -1,7 +1,7 @@ <script> import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import runnerJobsQuery from '../graphql/details/runner_jobs.query.graphql'; +import runnerJobsQuery from '../graphql/show/runner_jobs.query.graphql'; import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants'; import { captureException } from '../sentry_utils'; import { getPaginationVariables } from '../utils'; diff --git a/app/assets/javascripts/runner/components/runner_pagination.vue b/app/assets/javascripts/runner/components/runner_pagination.vue index b683a7f2330..cfc21d1407b 100644 --- a/app/assets/javascripts/runner/components/runner_pagination.vue +++ b/app/assets/javascripts/runner/components/runner_pagination.vue @@ -21,10 +21,10 @@ export default { }, computed: { prevPage() { - return this.pageInfo?.hasPreviousPage ? this.value?.page - 1 : null; + return this.pageInfo?.hasPreviousPage ? this.value.page - 1 : null; }, nextPage() { - return this.pageInfo?.hasNextPage ? this.value?.page + 1 : null; + return this.pageInfo?.hasNextPage ? this.value.page + 1 : null; }, }, methods: { diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue index d080d34fdd3..daca718e2b5 100644 --- a/app/assets/javascripts/runner/components/runner_projects.vue +++ b/app/assets/javascripts/runner/components/runner_projects.vue @@ -2,7 +2,7 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import { sprintf, formatNumber } from '~/locale'; import { createAlert } from '~/flash'; -import runnerProjectsQuery from '../graphql/details/runner_projects.query.graphql'; +import runnerProjectsQuery from '../graphql/show/runner_projects.query.graphql'; import { I18N_ASSIGNED_PROJECTS, I18N_NONE, diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue index 119e5236f85..56c9007a781 100644 --- a/app/assets/javascripts/runner/components/runner_update_form.vue +++ b/app/assets/javascripts/runner/components/runner_update_form.vue @@ -1,10 +1,12 @@ <script> import { GlButton, + GlIcon, GlForm, GlFormCheckbox, GlFormGroup, GlFormInputGroup, + GlSkeletonLoader, GlTooltipDirective, } from '@gitlab/ui'; import { @@ -12,19 +14,23 @@ import { runnerToModel, } from 'ee_else_ce/runner/runner_update_form_utils'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { captureException } from '~/runner/sentry_utils'; import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants'; -import runnerUpdateMutation from '../graphql/details/runner_update.mutation.graphql'; +import runnerUpdateMutation from '../graphql/edit/runner_update.mutation.graphql'; +import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; export default { name: 'RunnerUpdateForm', components: { GlButton, + GlIcon, GlForm, GlFormCheckbox, GlFormGroup, GlFormInputGroup, + GlSkeletonLoader, RunnerUpdateCostFactorFields: () => import('ee_component/runner/components/runner_update_cost_factor_fields.vue'), }, @@ -37,6 +43,16 @@ export default { required: false, default: null, }, + loading: { + type: Boolean, + required: false, + default: false, + }, + runnerPath: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -48,9 +64,6 @@ export default { canBeLockedToProject() { return this.runner?.runnerType === PROJECT_TYPE; }, - readonlyIpAddress() { - return this.runner?.ipAddress; - }, }, watch: { runner(newVal, oldVal) { @@ -74,24 +87,23 @@ export default { }); if (errors?.length) { - // Validation errors need not be thrown - createAlert({ message: errors[0] }); - return; + this.onError(errors[0]); + } else { + this.onSuccess(); } - - this.onSuccess(); } catch (error) { const { message } = error; - - createAlert({ message }); + this.onError(message); captureException({ error, component: this.$options.name }); - } finally { - this.saving = false; } }, onSuccess() { - createAlert({ message: __('Changes saved.'), variant: VARIANT_SUCCESS }); - this.model = runnerToModel(this.runner); + saveAlertToLocalStorage({ message: __('Changes saved.'), variant: VARIANT_SUCCESS }); + redirectTo(this.runnerPath); + }, + onError(message) { + this.saving = false; + createAlert({ message }); }, }, ACCESS_LEVEL_NOT_PROTECTED, @@ -100,104 +112,108 @@ export default { </script> <template> <gl-form @submit.prevent="onSubmit"> - <gl-form-checkbox - v-model="model.active" - data-testid="runner-field-paused" - :value="false" - :unchecked-value="true" - > - {{ __('Paused') }} - <template #help> - {{ s__('Runners|Stop the runner from accepting new jobs.') }} - </template> - </gl-form-checkbox> + <h4 class="gl-font-lg gl-my-5">{{ s__('Runners|Details') }}</h4> - <gl-form-checkbox - v-model="model.accessLevel" - data-testid="runner-field-protected" - :value="$options.ACCESS_LEVEL_REF_PROTECTED" - :unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED" - > - {{ __('Protected') }} - <template #help> - {{ s__('Runners|Use the runner on pipelines for protected branches only.') }} - </template> - </gl-form-checkbox> + <gl-skeleton-loader v-if="loading" /> + <gl-form-group v-else :label="__('Description')" data-testid="runner-field-description"> + <gl-form-input-group v-model="model.description" /> + </gl-form-group> - <gl-form-checkbox v-model="model.runUntagged" data-testid="runner-field-run-untagged"> - {{ __('Run untagged jobs') }} - <template #help> - {{ s__('Runners|Use the runner for jobs without tags, in addition to tagged jobs.') }} - </template> - </gl-form-checkbox> + <hr /> - <gl-form-checkbox - v-if="canBeLockedToProject" - v-model="model.locked" - data-testid="runner-field-locked" - > - {{ __('Lock to current projects') }} - <template #help> - {{ - s__( - 'Runners|Use the runner for the currently assigned projects only. Only administrators can change the assigned projects.', - ) - }} - </template> - </gl-form-checkbox> + <h4 class="gl-font-lg gl-my-5">{{ s__('Runners|Configuration') }}</h4> - <gl-form-group :label="__('IP Address')" data-testid="runner-field-ip-address"> - <gl-form-input-group :value="readonlyIpAddress" readonly select-on-click> - <template #append> - <gl-button - v-gl-tooltip.hover - :title="__('Copy IP Address')" - :aria-label="__('Copy IP Address')" - :data-clipboard-text="readonlyIpAddress" - icon="copy-to-clipboard" - class="d-inline-flex" - /> - </template> - </gl-form-input-group> - </gl-form-group> + <template v-if="loading"> + <gl-skeleton-loader v-for="i in 3" :key="i" /> + </template> + <template v-else> + <div class="gl-mb-5"> + <gl-form-checkbox + v-model="model.active" + data-testid="runner-field-paused" + :value="false" + :unchecked-value="true" + > + {{ __('Paused') }} + <template #help> + {{ s__('Runners|Stop the runner from accepting new jobs.') }} + </template> + </gl-form-checkbox> - <gl-form-group :label="__('Description')" data-testid="runner-field-description"> - <gl-form-input-group v-model="model.description" /> - </gl-form-group> + <gl-form-checkbox + v-model="model.accessLevel" + data-testid="runner-field-protected" + :value="$options.ACCESS_LEVEL_REF_PROTECTED" + :unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED" + > + {{ __('Protected') }} + <template #help> + {{ s__('Runners|Use the runner on pipelines for protected branches only.') }} + </template> + </gl-form-checkbox> - <gl-form-group - data-testid="runner-field-max-timeout" - :label="__('Maximum job timeout')" - :description=" - s__( - 'Runners|Enter the number of seconds. This timeout takes precedence over lower timeouts set for the project.', - ) - " - > - <gl-form-input-group v-model.number="model.maximumTimeout" type="number" /> - </gl-form-group> + <gl-form-checkbox v-model="model.runUntagged" data-testid="runner-field-run-untagged"> + {{ __('Run untagged jobs') }} + <template #help> + {{ s__('Runners|Use the runner for jobs without tags, in addition to tagged jobs.') }} + </template> + </gl-form-checkbox> - <gl-form-group - data-testid="runner-field-tags" - :label="__('Tags')" - :description=" - __('You can set up jobs to only use runners with specific tags. Separate tags with commas.') - " - > - <gl-form-input-group v-model="model.tagList" /> - </gl-form-group> + <gl-form-checkbox + v-if="canBeLockedToProject" + v-model="model.locked" + data-testid="runner-field-locked" + > + {{ __('Lock to current projects') }} <gl-icon name="lock" /> + <template #help> + {{ + s__( + 'Runners|Use the runner for the currently assigned projects only. Only administrators can change the assigned projects.', + ) + }} + </template> + </gl-form-checkbox> + </div> - <runner-update-cost-factor-fields v-model="model" /> + <gl-form-group + data-testid="runner-field-max-timeout" + :label="__('Maximum job timeout')" + :description=" + s__( + 'Runners|Enter the number of seconds. This timeout takes precedence over lower timeouts set for the project.', + ) + " + > + <gl-form-input-group v-model.number="model.maximumTimeout" type="number" /> + </gl-form-group> + + <gl-form-group + data-testid="runner-field-tags" + :label="__('Tags')" + :description=" + __( + 'You can set up jobs to only use runners with specific tags. Separate tags with commas.', + ) + " + > + <gl-form-input-group v-model="model.tagList" /> + </gl-form-group> + + <runner-update-cost-factor-fields v-model="model" /> + </template> - <div class="form-actions"> + <div class="gl-mt-6"> <gl-button type="submit" variant="confirm" class="js-no-auto-disable" - :loading="saving || !runner" + :loading="loading || saving" > {{ __('Save changes') }} </gl-button> + <gl-button :href="runnerPath"> + {{ __('Cancel') }} + </gl-button> </div> </gl-form> </template> diff --git a/app/assets/javascripts/runner/graphql/details/runner.query.graphql b/app/assets/javascripts/runner/graphql/details/runner.query.graphql deleted file mode 100644 index 4792a186160..00000000000 --- a/app/assets/javascripts/runner/graphql/details/runner.query.graphql +++ /dev/null @@ -1,9 +0,0 @@ -#import "ee_else_ce/runner/graphql/details/runner_details.fragment.graphql" - -query getRunner($id: CiRunnerID!) { - # We have an id in deeply nested fragment - # eslint-disable-next-line @graphql-eslint/require-id-when-available - runner(id: $id) { - ...RunnerDetails - } -} diff --git a/app/assets/javascripts/runner/graphql/details/runner_details.fragment.graphql b/app/assets/javascripts/runner/graphql/details/runner_details.fragment.graphql deleted file mode 100644 index 2449ee0fc0f..00000000000 --- a/app/assets/javascripts/runner/graphql/details/runner_details.fragment.graphql +++ /dev/null @@ -1,5 +0,0 @@ -#import "./runner_details_shared.fragment.graphql" - -fragment RunnerDetails on CiRunner { - ...RunnerDetailsShared -} diff --git a/app/assets/javascripts/runner/graphql/details/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/details/runner_details_shared.fragment.graphql deleted file mode 100644 index d8c67728fac..00000000000 --- a/app/assets/javascripts/runner/graphql/details/runner_details_shared.fragment.graphql +++ /dev/null @@ -1,35 +0,0 @@ -fragment RunnerDetailsShared on CiRunner { - __typename - id - runnerType - active - accessLevel - runUntagged - locked - ipAddress - description - maximumTimeout - jobCount - tagList - createdAt - status(legacyMode: null) - contactedAt - version - editAdminUrl - userPermissions { - updateRunner - deleteRunner - } - groups { - # Only a single group can be loaded here, while projects - # are loaded separately using the query with pagination - # parameters `runner_projects.query.graphql`. - nodes { - id - avatarUrl - name - fullName - webUrl - } - } -} diff --git a/app/assets/javascripts/runner/graphql/details/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/details/runner_update.mutation.graphql deleted file mode 100644 index e4bf51e2c30..00000000000 --- a/app/assets/javascripts/runner/graphql/details/runner_update.mutation.graphql +++ /dev/null @@ -1,15 +0,0 @@ -#import "ee_else_ce/runner/graphql/details/runner_details.fragment.graphql" - -# Mutation for updates from the runner form, loads -# attributes shown in the runner details. - -mutation runnerUpdate($input: RunnerUpdateInput!) { - runnerUpdate(input: $input) { - # We have an id in deep nested fragment - # eslint-disable-next-line @graphql-eslint/require-id-when-available - runner { - ...RunnerDetails - } - errors - } -} diff --git a/app/assets/javascripts/runner/graphql/edit/runner_fields.fragment.graphql b/app/assets/javascripts/runner/graphql/edit/runner_fields.fragment.graphql new file mode 100644 index 00000000000..b732d587d70 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/edit/runner_fields.fragment.graphql @@ -0,0 +1,5 @@ +#import "./runner_fields_shared.fragment.graphql" + +fragment RunnerFields on CiRunner { + ...RunnerFieldsShared +} diff --git a/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql new file mode 100644 index 00000000000..f900a0450e5 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql @@ -0,0 +1,15 @@ +fragment RunnerFieldsShared on CiRunner { + __typename + id + shortSha + runnerType + active + accessLevel + runUntagged + locked + description + maximumTimeout + tagList + createdAt + status(legacyMode: null) +} diff --git a/app/assets/javascripts/runner/graphql/edit/runner_form.query.graphql b/app/assets/javascripts/runner/graphql/edit/runner_form.query.graphql new file mode 100644 index 00000000000..0bf66c223fc --- /dev/null +++ b/app/assets/javascripts/runner/graphql/edit/runner_form.query.graphql @@ -0,0 +1,7 @@ +#import "ee_else_ce/runner/graphql/edit/runner_fields.fragment.graphql" + +query getRunnerForm($id: CiRunnerID!) { + runner(id: $id) { + ...RunnerFields + } +} diff --git a/app/assets/javascripts/runner/graphql/edit/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/edit/runner_update.mutation.graphql new file mode 100644 index 00000000000..8694a51b5a4 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/edit/runner_update.mutation.graphql @@ -0,0 +1,13 @@ +#import "ee_else_ce/runner/graphql/edit/runner_fields.fragment.graphql" + +# Mutation for updates from the runner form, loads +# attributes shown in the runner details. + +mutation runnerUpdate($input: RunnerUpdateInput!) { + runnerUpdate(input: $input) { + runner { + ...RunnerFields + } + errors + } +} diff --git a/app/assets/javascripts/runner/graphql/show/runner.query.graphql b/app/assets/javascripts/runner/graphql/show/runner.query.graphql new file mode 100644 index 00000000000..178816b58bd --- /dev/null +++ b/app/assets/javascripts/runner/graphql/show/runner.query.graphql @@ -0,0 +1,41 @@ +query getRunner($id: CiRunnerID!) { + runner(id: $id) { + __typename + id + shortSha + runnerType + active + accessLevel + runUntagged + locked + ipAddress + executorName + architectureName + platformName + description + maximumTimeout + jobCount + tagList + createdAt + status(legacyMode: null) + contactedAt + version + editAdminUrl + userPermissions { + updateRunner + deleteRunner + } + groups { + # Only a single group can be loaded here, while projects + # are loaded separately using the query with pagination + # parameters `runner_projects.query.graphql`. + nodes { + id + avatarUrl + name + fullName + webUrl + } + } + } +} diff --git a/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql b/app/assets/javascripts/runner/graphql/show/runner_jobs.query.graphql index 14585e62bf2..14585e62bf2 100644 --- a/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql +++ b/app/assets/javascripts/runner/graphql/show/runner_jobs.query.graphql diff --git a/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql index cb27de7c200..cb27de7c200 100644 --- a/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql +++ b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index b299d7c40fe..b5bd4b111fd 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -34,7 +34,7 @@ import { } from '../runner_search_utils'; import { captureException } from '../sentry_utils'; -const runnersCountSmartQuery = { +const countSmartQuery = () => ({ query: groupRunnersCountQuery, fetchPolicy: fetchPolicies.NETWORK_ONLY, update(data) { @@ -43,6 +43,39 @@ const runnersCountSmartQuery = { error(error) { this.reportToSentry(error); }, +}); + +const tabCountSmartQuery = ({ type }) => { + return { + ...countSmartQuery(), + variables() { + return { + ...this.countVariables, + type, + }; + }, + }; +}; + +const statusCountSmartQuery = ({ status, name }) => { + return { + ...countSmartQuery(), + skip() { + // skip if filtering by status and not using _this_ status as filter + if (this.countVariables.status && this.countVariables.status !== status) { + // reset count for given status + this[name] = null; + return true; + } + return false; + }, + variables() { + return { + ...this.countVariables, + status, + }; + }, + }; }; export default { @@ -116,59 +149,27 @@ export default { this.reportToSentry(error); }, }, - onlineRunnersTotal: { - ...runnersCountSmartQuery, - variables() { - return { - groupFullPath: this.groupFullPath, - status: STATUS_ONLINE, - }; - }, - }, - offlineRunnersTotal: { - ...runnersCountSmartQuery, - variables() { - return { - groupFullPath: this.groupFullPath, - status: STATUS_OFFLINE, - }; - }, - }, - staleRunnersTotal: { - ...runnersCountSmartQuery, - variables() { - return { - groupFullPath: this.groupFullPath, - status: STATUS_STALE, - }; - }, - }, + + // Tabs counts allRunnersCount: { - ...runnersCountSmartQuery, - variables() { - return { - ...this.countVariables, - type: null, - }; - }, + ...tabCountSmartQuery({ type: null }), }, groupRunnersCount: { - ...runnersCountSmartQuery, - variables() { - return { - ...this.countVariables, - type: GROUP_TYPE, - }; - }, + ...tabCountSmartQuery({ type: GROUP_TYPE }), }, projectRunnersCount: { - ...runnersCountSmartQuery, - variables() { - return { - ...this.countVariables, - type: PROJECT_TYPE, - }; - }, + ...tabCountSmartQuery({ type: PROJECT_TYPE }), + }, + + // Runner status summary + onlineRunnersTotal: { + ...statusCountSmartQuery({ status: STATUS_ONLINE, name: 'onlineRunnersTotal' }), + }, + offlineRunnersTotal: { + ...statusCountSmartQuery({ status: STATUS_OFFLINE, name: 'offlineRunnersTotal' }), + }, + staleRunnersTotal: { + ...statusCountSmartQuery({ status: STATUS_STALE, name: 'staleRunnersTotal' }), }, }, computed: { @@ -263,12 +264,6 @@ export default { <template> <div> - <runner-stats - :online-runners-count="onlineRunnersTotal" - :offline-runners-count="offlineRunnersTotal" - :stale-runners-count="staleRunnersTotal" - /> - <div class="gl-display-flex gl-align-items-center"> <runner-type-tabs v-model="search" @@ -298,6 +293,12 @@ export default { :namespace="filteredSearchNamespace" /> + <runner-stats + :online-runners-count="onlineRunnersTotal" + :offline-runners-count="offlineRunnersTotal" + :stale-runners-count="staleRunnersTotal" + /> + <div v-if="noRunnersFound" class="gl-text-center gl-p-5"> {{ __('No runners found') }} </div> diff --git a/app/assets/javascripts/runner/local_storage_alert/constants.js b/app/assets/javascripts/runner/local_storage_alert/constants.js new file mode 100644 index 00000000000..69b7418f898 --- /dev/null +++ b/app/assets/javascripts/runner/local_storage_alert/constants.js @@ -0,0 +1 @@ +export const LOCAL_STORAGE_ALERT_KEY = 'local-storage-alert'; diff --git a/app/assets/javascripts/runner/local_storage_alert/save_alert_to_local_storage.js b/app/assets/javascripts/runner/local_storage_alert/save_alert_to_local_storage.js new file mode 100644 index 00000000000..ca7c627459a --- /dev/null +++ b/app/assets/javascripts/runner/local_storage_alert/save_alert_to_local_storage.js @@ -0,0 +1,8 @@ +import AccessorUtilities from '~/lib/utils/accessor'; +import { LOCAL_STORAGE_ALERT_KEY } from './constants'; + +export const saveAlertToLocalStorage = (alertOptions) => { + if (AccessorUtilities.canUseLocalStorage()) { + localStorage.setItem(LOCAL_STORAGE_ALERT_KEY, JSON.stringify(alertOptions)); + } +}; diff --git a/app/assets/javascripts/runner/local_storage_alert/show_alert_from_local_storage.js b/app/assets/javascripts/runner/local_storage_alert/show_alert_from_local_storage.js new file mode 100644 index 00000000000..d768a06494a --- /dev/null +++ b/app/assets/javascripts/runner/local_storage_alert/show_alert_from_local_storage.js @@ -0,0 +1,18 @@ +import AccessorUtilities from '~/lib/utils/accessor'; +import { LOCAL_STORAGE_ALERT_KEY } from './constants'; + +export const showAlertFromLocalStorage = async () => { + if (AccessorUtilities.canUseLocalStorage()) { + const alertOptions = localStorage.getItem(LOCAL_STORAGE_ALERT_KEY); + + if (alertOptions) { + try { + const { createAlert } = await import('~/flash'); + createAlert(JSON.parse(alertOptions)); + } catch { + // ignore when the alert data cannot be parsed + } + } + localStorage.removeItem(LOCAL_STORAGE_ALERT_KEY); + } +}; diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js index 5e3c412ddb6..0d688ed65ef 100644 --- a/app/assets/javascripts/runner/runner_search_utils.js +++ b/app/assets/javascripts/runner/runner_search_utils.js @@ -18,7 +18,6 @@ import { PARAM_KEY_BEFORE, DEFAULT_SORT, RUNNER_PAGE_SIZE, - STATUS_NEVER_CONTACTED, } from './constants'; import { getPaginationVariables } from './utils'; @@ -84,7 +83,6 @@ const getPaginationFromParams = (params) => { }; // Outdated URL parameters -const STATUS_NOT_CONNECTED = 'NOT_CONNECTED'; const STATUS_ACTIVE = 'ACTIVE'; const STATUS_PAUSED = 'PAUSED'; @@ -116,10 +114,6 @@ export const updateOutdatedUrl = (url = window.location.href) => { const status = params[PARAM_KEY_STATUS]?.[0] || null; switch (status) { - case STATUS_NOT_CONNECTED: - return updateUrlParams(url, { - [PARAM_KEY_STATUS]: [STATUS_NEVER_CONTACTED], - }); case STATUS_ACTIVE: return updateUrlParams(url, { [PARAM_KEY_PAUSED]: ['false'], diff --git a/app/assets/javascripts/runner/utils.js b/app/assets/javascripts/runner/utils.js index 1f7794720de..cb2917a92fd 100644 --- a/app/assets/javascripts/runner/utils.js +++ b/app/assets/javascripts/runner/utils.js @@ -1,5 +1,4 @@ import { formatNumber } from '~/locale'; -import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; import { RUNNER_JOB_COUNT_LIMIT } from './constants'; /** @@ -28,7 +27,7 @@ export const tableField = ({ key, label = '', thClasses = [], ...options }) => { return { key, label, - thClass: [DEFAULT_TH_CLASSES, ...thClasses], + thClass: thClasses, tdAttr: { 'data-testid': `td-${key}`, }, diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index ba0120a0a70..d0c4ad3646c 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -1,15 +1,15 @@ <script> import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue'; +import currentLicenseQuery from '~/security_configuration/graphql/current_license.query.graphql'; import AutoDevOpsAlert from './auto_dev_ops_alert.vue'; import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue'; -import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants'; +import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, LICENSE_ULTIMATE } from './constants'; import FeatureCard from './feature_card.vue'; import TrainingProviderList from './training_provider_list.vue'; -import SectionLayout from './section_layout.vue'; import UpgradeBanner from './upgrade_banner.vue'; export const i18n = { @@ -50,8 +50,18 @@ export default { UserCalloutDismisser, TrainingProviderList, }, - mixins: [glFeatureFlagsMixin()], inject: ['projectFullPath', 'vulnerabilityTrainingDocsPath'], + apollo: { + currentLicensePlan: { + query: currentLicenseQuery, + update({ currentLicense }) { + return currentLicense?.plan; + }, + error() { + this.hasCurrentLicenseFetchError = true; + }, + }, + }, props: { augmentedSecurityFeatures: { type: Array, @@ -91,6 +101,8 @@ export default { return { autoDevopsEnabledAlertDismissedProjects: [], errorMessage: '', + currentLicensePlan: '', + hasCurrentLicenseFetchError: false, }; }, computed: { @@ -111,6 +123,12 @@ export default { !this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectFullPath) ); }, + shouldShowVulnerabilityManagementTab() { + // if the query fails (if the plan is `null` also means an error has occurred) we still want to show the feature + const hasQueryError = this.hasCurrentLicenseFetchError || this.currentLicensePlan === null; + + return hasQueryError || this.currentLicensePlan === LICENSE_ULTIMATE; + }, }, methods: { dismissAutoDevopsEnabledAlert() { @@ -175,7 +193,7 @@ export default { @dismiss="dismissAutoDevopsEnabledAlert" /> - <section-layout :heading="$options.i18n.securityTesting"> + <section-layout class="gl-border-b-0" :heading="$options.i18n.securityTesting"> <template #description> <p> <span data-testid="latest-pipeline-info-security"> @@ -252,7 +270,7 @@ export default { </section-layout> </gl-tab> <gl-tab - v-if="glFeatures.secureVulnerabilityTraining" + v-if="shouldShowVulnerabilityManagementTab" data-testid="vulnerability-management-tab" :title="$options.i18n.vulnerabilityManagement" query-param-value="vulnerability-management" diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 6db28ef0fad..5b04ad6f9ba 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -55,7 +55,7 @@ export const DAST_DESCRIPTION = s__( ); export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index'); export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', { - anchor: 'enable-dast', + anchor: 'enable-automatic-dast-run', }); export const DAST_BADGE_TEXT = __('Available on-demand'); export const DAST_BADGE_TOOLTIP = __( @@ -126,7 +126,7 @@ export const COVERAGE_FUZZING_HELP_PATH = helpPagePath( ); export const COVERAGE_FUZZING_CONFIG_HELP_PATH = helpPagePath( 'user/application_security/coverage_fuzzing/index', - { anchor: 'configuration' }, + { anchor: 'enable-coverage-guided-fuzz-testing' }, ); export const CORPUS_MANAGEMENT_NAME = __('Corpus Management'); @@ -310,3 +310,7 @@ export const TEMP_PROVIDER_URLS = { Kontra: 'https://application.security/', [__('Secure Code Warrior')]: 'https://www.securecodewarrior.com/', }; + +export const LICENSE_ULTIMATE = 'ultimate'; +export const LICENSE_FREE = 'free'; +export const LICENSE_PREMIUM = 'premium'; diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index 309e5f21445..19b412d66ca 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -119,8 +119,10 @@ export default { /> <template v-if="enabled"> - <gl-icon name="check-circle-filled" /> - <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span> + <span> + <gl-icon name="check-circle-filled" /> + <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span> + </span> </template> <template v-else-if="available"> diff --git a/app/assets/javascripts/security_configuration/components/section_layout.vue b/app/assets/javascripts/security_configuration/components/section_layout.vue deleted file mode 100644 index 1fe8dd862a0..00000000000 --- a/app/assets/javascripts/security_configuration/components/section_layout.vue +++ /dev/null @@ -1,23 +0,0 @@ -<script> -export default { - name: 'SectionLayout', - props: { - heading: { - type: String, - required: true, - }, - }, -}; -</script> - -<template> - <div class="row gl-line-height-20 gl-pt-6"> - <div class="col-lg-4"> - <h2 class="gl-font-size-h2 gl-mt-0">{{ heading }}</h2> - <slot name="description"></slot> - </div> - <div class="col-lg-8"> - <slot name="features"></slot> - </div> - </div> -</template> diff --git a/app/assets/javascripts/security_configuration/graphql/current_license.query.graphql b/app/assets/javascripts/security_configuration/graphql/current_license.query.graphql new file mode 100644 index 00000000000..9ab4f4d4347 --- /dev/null +++ b/app/assets/javascripts/security_configuration/graphql/current_license.query.graphql @@ -0,0 +1,6 @@ +query getCurrentLicensePlan { + currentLicense { + id + plan + } +} diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql index f0474614dab..891e0dda312 100644 --- a/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql +++ b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql @@ -5,6 +5,7 @@ query getSecurityTrainingUrls($projectFullPath: ID!, $identifierExternalIds: [St name status url + identifier } } } diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index 65cf1ec27a3..dcc41a38067 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -37,6 +37,7 @@ export const initSecurityConfiguration = (el) => { return new Vue({ el, apolloProvider, + name: 'SecurityConfigurationRoot', provide: { projectFullPath, upgradePath, diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue deleted file mode 100644 index a9584c070fe..00000000000 --- a/app/assets/javascripts/serverless/components/area.vue +++ /dev/null @@ -1,145 +0,0 @@ -<script> -import { GlAreaChart } from '@gitlab/ui/dist/charts'; -import dateFormat from 'dateformat'; -import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; -import { __ } from '~/locale'; -import { X_INTERVAL } from '../constants'; -import { validateGraphData } from '../utils'; - -let debouncedResize; - -export default { - components: { - GlAreaChart, - }, - inheritAttrs: false, - props: { - graphData: { - type: Object, - required: true, - validator: validateGraphData, - }, - containerWidth: { - type: Number, - required: true, - }, - }, - data() { - return { - tooltipPopoverTitle: '', - tooltipPopoverContent: '', - width: this.containerWidth, - }; - }, - computed: { - chartData() { - return this.graphData.queries.reduce((accumulator, query) => { - accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []); - return accumulator; - }, {}); - }, - extractTimeData() { - return this.chartData.requests.map((data) => data.time); - }, - generateSeries() { - return { - name: __('Invocations'), - type: 'line', - data: this.chartData.requests.map((data) => [data.time, data.value]), - symbolSize: 0, - }; - }, - getInterval() { - const { result } = this.graphData.queries[0]; - - if (result.length === 0) { - return 1; - } - - const split = result[0].values.reduce( - (acc, pair) => (pair.value > acc ? pair.value : acc), - 1, - ); - - return split < X_INTERVAL ? split : X_INTERVAL; - }, - chartOptions() { - return { - xAxis: { - name: 'time', - type: 'time', - axisLabel: { - formatter: (date) => dateFormat(date, 'h:MM TT'), - }, - data: this.extractTimeData, - nameTextStyle: { - padding: [18, 0, 0, 0], - }, - }, - yAxis: { - name: this.yAxisLabel, - nameTextStyle: { - padding: [0, 0, 36, 0], - }, - splitNumber: this.getInterval, - }, - legend: { - formatter: this.xAxisLabel, - }, - series: this.generateSeries, - }; - }, - xAxisLabel() { - return this.graphData.queries.map((query) => query.label).join(', '); - }, - yAxisLabel() { - const [query] = this.graphData.queries; - return `${this.graphData.y_label} (${query.unit})`; - }, - }, - watch: { - containerWidth: 'onResize', - }, - beforeDestroy() { - window.removeEventListener('resize', debouncedResize); - }, - created() { - debouncedResize = debounceByAnimationFrame(this.onResize); - window.addEventListener('resize', debouncedResize); - }, - methods: { - formatTooltipText(params) { - const [seriesData] = params.seriesData; - this.tooltipPopoverTitle = dateFormat(params.value, 'dd mmm yyyy, h:MMTT'); - this.tooltipPopoverContent = `${this.yAxisLabel}: ${seriesData.value[1]}`; - }, - onResize() { - const { width } = this.$refs.areaChart.$el.getBoundingClientRect(); - this.width = width; - }, - }, -}; -</script> - -<template> - <div class="prometheus-graph"> - <div class="prometheus-graph-header"> - <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> - <div ref="graphWidgets" class="prometheus-graph-widgets"> - <slot></slot> - </div> - </div> - <gl-area-chart - ref="areaChart" - v-bind="$attrs" - :data="[]" - :option="chartOptions" - :format-tooltip-text="formatTooltipText" - :width="width" - :include-legend-avg-max="false" - > - <template #tooltip-title>{{ tooltipPopoverTitle }}</template> - <template #tooltip-content>{{ tooltipPopoverContent }}</template> - </gl-area-chart> - </div> -</template> diff --git a/app/assets/javascripts/serverless/components/empty_state.vue b/app/assets/javascripts/serverless/components/empty_state.vue deleted file mode 100644 index 6d1cea519c4..00000000000 --- a/app/assets/javascripts/serverless/components/empty_state.vue +++ /dev/null @@ -1,39 +0,0 @@ -<script> -import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; -import { mapState } from 'vuex'; -import { s__ } from '~/locale'; -import { DEPRECATION_POST_LINK } from '../constants'; - -export default { - components: { - GlEmptyState, - GlLink, - GlSprintf, - }, - i18n: { - title: s__('Serverless|Getting started with serverless'), - description: s__( - 'Serverless|Serverless was %{postLinkStart}deprecated%{postLinkEnd}. But if you opt to use it, you must install Knative in your Kubernetes cluster first. %{linkStart}Learn more.%{linkEnd}', - ), - }, - deprecationPostLink: DEPRECATION_POST_LINK, - computed: { - ...mapState(['emptyImagePath', 'helpPath']), - }, -}; -</script> - -<template> - <gl-empty-state :svg-path="emptyImagePath" :title="$options.i18n.title"> - <template #description> - <gl-sprintf :message="$options.i18n.description"> - <template #postLink="{ content }"> - <gl-link :href="$options.deprecationPostLink" target="_blank">{{ content }}</gl-link> - </template> - <template #link="{ content }"> - <gl-link :href="helpPath">{{ content }}</gl-link> - </template> - </gl-sprintf> - </template> - </gl-empty-state> -</template> diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue deleted file mode 100644 index 01030172ea8..00000000000 --- a/app/assets/javascripts/serverless/components/environment_row.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> -import ItemCaret from '~/groups/components/item_caret.vue'; -import FunctionRow from './function_row.vue'; - -export default { - components: { - ItemCaret, - FunctionRow, - }, - props: { - env: { - type: Array, - required: true, - }, - envName: { - type: String, - required: true, - }, - }, - data() { - return { - isOpen: true, - }; - }, - computed: { - envId() { - if (this.envName === '*') { - return 'env-global'; - } - - return `env-${this.envName}`; - }, - isOpenClass() { - return { - 'is-open': this.isOpen, - }; - }, - }, - methods: { - toggleOpen() { - this.isOpen = !this.isOpen; - }, - }, -}; -</script> - -<template> - <li :id="envId" :class="isOpenClass" class="group-row has-children"> - <div - class="group-row-contents d-flex justify-content-end align-items-center py-2" - role="button" - @click.stop="toggleOpen" - > - <div class="folder-toggle-wrap d-flex align-items-center"> - <item-caret :is-group-open="isOpen" /> - </div> - <div class="group-text flex-grow title namespace-title gl-ml-3"> - {{ envName }} - </div> - </div> - <ul v-if="isOpen" class="content-list group-list-tree"> - <function-row v-for="(f, index) in env" :key="f.name" :index="index" :func="f" /> - </ul> - </li> -</template> diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue deleted file mode 100644 index d2306c2d8bd..00000000000 --- a/app/assets/javascripts/serverless/components/function_details.vue +++ /dev/null @@ -1,94 +0,0 @@ -<script> -import { isString } from 'lodash'; -import { mapState, mapActions, mapGetters } from 'vuex'; -import AreaChart from './area.vue'; -import MissingPrometheus from './missing_prometheus.vue'; -import PodBox from './pod_box.vue'; -import Url from './url.vue'; - -export default { - components: { - PodBox, - Url, - AreaChart, - MissingPrometheus, - }, - props: { - func: { - type: Object, - required: true, - }, - hasPrometheus: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - elWidth: 0, - }; - }, - computed: { - name() { - return this.func.name; - }, - description() { - return isString(this.func.description) ? this.func.description : ''; - }, - funcUrl() { - return this.func.url; - }, - podCount() { - return Number(this.func.podcount) || 0; - }, - ...mapState(['graphData', 'hasPrometheusData']), - ...mapGetters(['hasPrometheusMissingData']), - }, - created() { - this.fetchMetrics({ - metricsPath: this.func.metricsUrl, - hasPrometheus: this.hasPrometheus, - }); - }, - mounted() { - this.elWidth = this.$el.clientWidth; - }, - methods: { - ...mapActions(['fetchMetrics']), - }, -}; -</script> - -<template> - <section id="serverless-function-details"> - <h3 class="serverless-function-name">{{ name }}</h3> - <div class="gl-mb-3 serverless-function-description"> - <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div> - </div> - <url :uri="funcUrl" /> - - <h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4> - <div v-if="podCount > 0"> - <p> - <b v-if="podCount == 1">{{ podCount }} {{ s__('ServerlessDetails|pod in use') }}</b> - <b v-else>{{ podCount }} {{ s__('ServerlessDetails|pods in use') }}</b> - </p> - <pod-box :count="podCount" /> - <p> - {{ - s__('ServerlessDetails|Number of Kubernetes pods in use over time based on necessity.') - }} - </p> - </div> - <div v-else> - <p>{{ s__('ServerlessDetails|No pods loaded at this time.') }}</p> - </div> - - <area-chart v-if="hasPrometheusData" :graph-data="graphData" :container-width="elWidth" /> - <missing-prometheus - v-if="!hasPrometheus || hasPrometheusMissingData" - :missing-data="hasPrometheusMissingData" - /> - </section> -</template> diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue deleted file mode 100644 index fab9c0a75e7..00000000000 --- a/app/assets/javascripts/serverless/components/function_row.vue +++ /dev/null @@ -1,77 +0,0 @@ -<script> -import { isString } from 'lodash'; -import { visitUrl } from '~/lib/utils/url_utility'; -import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; -import Url from './url.vue'; - -export default { - components: { - Timeago, - Url, - }, - props: { - func: { - type: Object, - required: true, - }, - }, - computed: { - name() { - return this.func.name; - }, - description() { - if (!isString(this.func.description)) { - return ''; - } - - const desc = this.func.description.split('\n'); - if (desc.length > 1) { - return desc[1]; - } - - return desc[0]; - }, - detailUrl() { - return this.func.detail_url; - }, - targetUrl() { - return this.func.url; - }, - image() { - return this.func.image; - }, - timestamp() { - return this.func.created_at; - }, - }, - methods: { - checkClass(element) { - if (element.closest('.no-expand') === null) { - return true; - } - - return false; - }, - openDetails(e) { - if (this.checkClass(e.target)) { - visitUrl(this.detailUrl); - } - }, - }, -}; -</script> - -<template> - <li :id="name" class="group-row"> - <div class="group-row-contents py-2" role="button" @click="openDetails"> - <p class="float-right text-right"> - <span>{{ image }}</span - ><br /> - <timeago :time="timestamp" /> - </p> - <b>{{ name }}</b> - <div v-for="line in description.split('\n')" :key="line">{{ line }}</div> - <url :uri="targetUrl" class="gl-mt-3 no-expand" /> - </div> - </li> -</template> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue deleted file mode 100644 index e9461aa3ead..00000000000 --- a/app/assets/javascripts/serverless/components/functions.vue +++ /dev/null @@ -1,139 +0,0 @@ -<script> -import { - GlLink, - GlAlert, - GlSprintf, - GlLoadingIcon, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; -import { mapState, mapActions, mapGetters } from 'vuex'; -import { sprintf, s__ } from '~/locale'; -import { CHECKING_INSTALLED, DEPRECATION_POST_LINK } from '../constants'; -import EmptyState from './empty_state.vue'; -import EnvironmentRow from './environment_row.vue'; - -export default { - components: { - EnvironmentRow, - EmptyState, - GlLink, - GlAlert, - GlSprintf, - GlLoadingIcon, - }, - directives: { - SafeHtml, - }, - deprecationPostLink: DEPRECATION_POST_LINK, - computed: { - ...mapState(['installed', 'isLoading', 'hasFunctionData', 'helpPath', 'statusPath']), - ...mapGetters(['getFunctions']), - - checkingInstalled() { - return this.installed === CHECKING_INSTALLED; - }, - isInstalled() { - return this.installed === true; - }, - noServerlessConfigFile() { - return sprintf( - s__( - 'Serverless|Your repository does not have a corresponding %{startTag}serverless.yml%{endTag} file.', - ), - { startTag: '<code>', endTag: '</code>' }, - false, - ); - }, - noGitlabYamlConfigured() { - return sprintf( - s__('Serverless|Your %{startTag}.gitlab-ci.yml%{endTag} file is not properly configured.'), - { startTag: '<code>', endTag: '</code>' }, - false, - ); - }, - mismatchedServerlessFunctions() { - return sprintf( - s__( - "Serverless|The functions listed in the %{startTag}serverless.yml%{endTag} file don't match the namespace of your cluster.", - ), - { startTag: '<code>', endTag: '</code>' }, - false, - ); - }, - }, - created() { - this.fetchFunctions({ - functionsPath: this.statusPath, - }); - }, - methods: { - ...mapActions(['fetchFunctions']), - }, -}; -</script> - -<template> - <section id="serverless-functions" class="flex-grow"> - <gl-alert class="gl-mt-6" variant="warning" :dismissible="false"> - <gl-sprintf - :message="s__('Serverless|Serverless was %{linkStart}deprecated%{linkEnd} in GitLab 14.3.')" - ><template #link="{ content }" - ><gl-link :href="$options.deprecationPostLink" target="_blank">{{ - content - }}</gl-link></template - ></gl-sprintf - > - </gl-alert> - - <gl-loading-icon v-if="checkingInstalled" size="lg" class="gl-mt-3 gl-mb-3" /> - - <div v-else-if="isInstalled"> - <div v-if="hasFunctionData"> - <div class="groups-list-tree-container js-functions-wrapper"> - <ul class="content-list group-list-tree"> - <environment-row - v-for="(env, index) in getFunctions" - :key="index" - :env="env" - :env-name="index" - /> - </ul> - </div> - <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3 js-functions-loader" /> - </div> - <div v-else class="empty-state js-empty-state"> - <div class="text-content"> - <h4 class="state-title text-center">{{ s__('Serverless|No functions available') }}</h4> - <p class="state-description"> - {{ - s__( - 'Serverless|There is currently no function data available from Knative. This could be for a variety of reasons including:', - ) - }} - </p> - <ul> - <li v-safe-html="noServerlessConfigFile"></li> - <li v-safe-html="noGitlabYamlConfigured"></li> - <li v-safe-html="mismatchedServerlessFunctions"></li> - <li>{{ s__('Serverless|The deploy job has not finished.') }}</li> - </ul> - - <p> - {{ - s__( - 'Serverless|If you believe none of these apply, please check back later as the function data may be in the process of becoming available.', - ) - }} - </p> - <div class="text-center"> - <gl-link :href="helpPath" class="btn btn-success">{{ - s__('Serverless|Learn more about Serverless') - }}</gl-link> - </div> - </div> - </div> - </div> - - <empty-state v-else /> - </section> -</template> diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue deleted file mode 100644 index d9e6bb5009e..00000000000 --- a/app/assets/javascripts/serverless/components/missing_prometheus.vue +++ /dev/null @@ -1,57 +0,0 @@ -<script> -import { GlButton, GlLink } from '@gitlab/ui'; -import { mapState } from 'vuex'; -import { s__ } from '~/locale'; - -export default { - components: { - GlButton, - GlLink, - }, - props: { - missingData: { - type: Boolean, - required: true, - }, - }, - computed: { - ...mapState(['clustersPath', 'helpPath']), - missingStateClass() { - return this.missingData ? 'missing-prometheus-state' : 'empty-prometheus-state'; - }, - prometheusHelpPath() { - return `${this.helpPath}#prometheus-support`; - }, - description() { - return this.missingData - ? s__(`ServerlessDetails|Invocation metrics loading or not available at this time.`) - : s__( - `ServerlessDetails|Function invocation metrics require the Prometheus cluster integration.`, - ); - }, - }, -}; -</script> - -<template> - <div class="row" :class="missingStateClass"> - <div class="col-12"> - <div class="text-content"> - <h4 class="state-title text-left">{{ s__(`ServerlessDetails|Invocations`) }}</h4> - <p class="state-description"> - {{ description }} - <gl-link :href="prometheusHelpPath">{{ - s__(`ServerlessDetails|More information`) - }}</gl-link - >. - </p> - - <div v-if="!missingData" class="text-left"> - <gl-button :href="clustersPath" variant="success" category="primary"> - {{ s__('ServerlessDetails|Configure cluster.') }} - </gl-button> - </div> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/serverless/components/pod_box.vue b/app/assets/javascripts/serverless/components/pod_box.vue deleted file mode 100644 index 04d3641bce3..00000000000 --- a/app/assets/javascripts/serverless/components/pod_box.vue +++ /dev/null @@ -1,36 +0,0 @@ -<script> -export default { - props: { - count: { - type: Number, - required: true, - }, - color: { - type: String, - required: false, - default: 'green', - }, - }, - methods: { - boxOffset(i) { - return 20 * (i - 1); - }, - }, -}; -</script> - -<template> - <svg :width="boxOffset(count + 1)" :height="20"> - <rect - v-for="i in count" - :key="i" - width="15" - height="15" - rx="5" - ry="5" - :fill="color" - :x="boxOffset(i)" - y="0" - /> - </svg> -</template> diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue deleted file mode 100644 index b105f49e475..00000000000 --- a/app/assets/javascripts/serverless/components/url.vue +++ /dev/null @@ -1,28 +0,0 @@ -<script> -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; - -export default { - components: { - ClipboardButton, - }, - props: { - uri: { - type: String, - required: true, - }, - }, -}; -</script> - -<template> - <div class="clipboard-group"> - <div class="gl-cursor-text label label-monospace monospace" data-testid="url-text-field"> - {{ uri }} - </div> - <clipboard-button - :text="uri" - :title="s__('ServerlessURL|Copy URL')" - class="input-group-text js-clipboard-btn" - /> - </div> -</template> diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js deleted file mode 100644 index 42c9ee983b4..00000000000 --- a/app/assets/javascripts/serverless/constants.js +++ /dev/null @@ -1,10 +0,0 @@ -export const MAX_REQUESTS = 3; // max number of times to retry - -export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-axis - -export const CHECKING_INSTALLED = 'checking'; // The backend is still determining whether or not Knative is installed - -export const TIMEOUT = 'timeout'; - -export const DEPRECATION_POST_LINK = - 'https://about.gitlab.com/releases/2021/09/22/gitlab-14-3-released/#gitlab-serverless'; diff --git a/app/assets/javascripts/serverless/event_hub.js b/app/assets/javascripts/serverless/event_hub.js deleted file mode 100644 index e31806ad199..00000000000 --- a/app/assets/javascripts/serverless/event_hub.js +++ /dev/null @@ -1,3 +0,0 @@ -import createEventHub from '~/helpers/event_hub_factory'; - -export default createEventHub(); diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js deleted file mode 100644 index e8d87a40fc7..00000000000 --- a/app/assets/javascripts/serverless/serverless_bundle.js +++ /dev/null @@ -1,67 +0,0 @@ -import Vue from 'vue'; -import FunctionDetails from './components/function_details.vue'; -import Functions from './components/functions.vue'; -import { createStore } from './store'; - -export default class Serverless { - constructor() { - if (document.querySelector('.js-serverless-function-details-page') != null) { - const entryPointData = document.querySelector('.js-serverless-function-details-page').dataset; - const store = createStore(entryPointData); - - const { - serviceName, - serviceDescription, - serviceEnvironment, - serviceUrl, - serviceNamespace, - servicePodcount, - serviceMetricsUrl, - prometheus, - } = entryPointData; - const el = document.querySelector('#js-serverless-function-details'); - - const service = { - name: serviceName, - description: serviceDescription, - environment: serviceEnvironment, - url: serviceUrl, - namespace: serviceNamespace, - podcount: servicePodcount, - metricsUrl: serviceMetricsUrl, - }; - - this.functionDetails = new Vue({ - el, - store, - render(createElement) { - return createElement(FunctionDetails, { - props: { - func: service, - hasPrometheus: prometheus !== undefined, - }, - }); - }, - }); - } else { - const entryPointData = document.querySelector('.js-serverless-functions-page').dataset; - const store = createStore(entryPointData); - - const el = document.querySelector('#js-serverless-functions'); - this.functions = new Vue({ - el, - store, - render(createElement) { - return createElement(Functions); - }, - }); - } - } - - destroy() { - this.destroyed = true; - - this.functions.$destroy(); - this.functionDetails.$destroy(); - } -} diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js deleted file mode 100644 index 166cd796680..00000000000 --- a/app/assets/javascripts/serverless/store/actions.js +++ /dev/null @@ -1,131 +0,0 @@ -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { backOff } from '~/lib/utils/common_utils'; -import statusCodes from '~/lib/utils/http_status'; -import { __ } from '~/locale'; -import { MAX_REQUESTS, CHECKING_INSTALLED, TIMEOUT } from '../constants'; -import * as types from './mutation_types'; - -export const requestFunctionsLoading = ({ commit }) => commit(types.REQUEST_FUNCTIONS_LOADING); -export const receiveFunctionsSuccess = ({ commit }, data) => - commit(types.RECEIVE_FUNCTIONS_SUCCESS, data); -export const receiveFunctionsPartial = ({ commit }, data) => - commit(types.RECEIVE_FUNCTIONS_PARTIAL, data); -export const receiveFunctionsTimeout = ({ commit }, data) => - commit(types.RECEIVE_FUNCTIONS_TIMEOUT, data); -export const receiveFunctionsNoDataSuccess = ({ commit }, data) => - commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS, data); -export const receiveFunctionsError = ({ commit }, error) => - commit(types.RECEIVE_FUNCTIONS_ERROR, error); - -export const receiveMetricsSuccess = ({ commit }, data) => - commit(types.RECEIVE_METRICS_SUCCESS, data); -export const receiveMetricsNoPrometheus = ({ commit }) => - commit(types.RECEIVE_METRICS_NO_PROMETHEUS); -export const receiveMetricsNoDataSuccess = ({ commit }, data) => - commit(types.RECEIVE_METRICS_NODATA_SUCCESS, data); -export const receiveMetricsError = ({ commit }, error) => - commit(types.RECEIVE_METRICS_ERROR, error); - -export const fetchFunctions = ({ dispatch }, { functionsPath }) => { - let retryCount = 0; - - const functionsPartiallyFetched = (data) => { - if (data.functions !== null && data.functions.length) { - dispatch('receiveFunctionsPartial', data); - } - }; - - dispatch('requestFunctionsLoading'); - - backOff((next, stop) => { - axios - .get(functionsPath) - .then((response) => { - if (response.data.knative_installed === CHECKING_INSTALLED) { - retryCount += 1; - if (retryCount < MAX_REQUESTS) { - functionsPartiallyFetched(response.data); - next(); - } else { - stop(TIMEOUT); - } - } else { - stop(response.data); - } - }) - .catch(stop); - }) - .then((data) => { - if (data === TIMEOUT) { - dispatch('receiveFunctionsTimeout'); - createFlash({ - message: __('Loading functions timed out. Please reload the page to try again.'), - }); - } else if (data.functions !== null && data.functions.length) { - dispatch('receiveFunctionsSuccess', data); - } else { - dispatch('receiveFunctionsNoDataSuccess', data); - } - }) - .catch((error) => { - dispatch('receiveFunctionsError', error); - createFlash({ - message: error, - }); - }); -}; - -export const fetchMetrics = ({ dispatch }, { metricsPath, hasPrometheus }) => { - let retryCount = 0; - - if (!hasPrometheus) { - dispatch('receiveMetricsNoPrometheus'); - return; - } - - backOff((next, stop) => { - axios - .get(metricsPath) - .then((response) => { - if (response.status === statusCodes.NO_CONTENT) { - retryCount += 1; - if (retryCount < MAX_REQUESTS) { - next(); - } else { - dispatch('receiveMetricsNoDataSuccess'); - stop(null); - } - } else { - stop(response.data); - } - }) - .catch(stop); - }) - .then((data) => { - if (data === null) { - return; - } - - const updatedMetric = data.metrics; - const queries = data.metrics.queries.map((query) => ({ - ...query, - result: query.result.map((result) => ({ - ...result, - values: result.values.map(([timestamp, value]) => ({ - time: new Date(timestamp * 1000).toISOString(), - value: Number(value), - })), - })), - })); - - updatedMetric.queries = queries; - dispatch('receiveMetricsSuccess', updatedMetric); - }) - .catch((error) => { - dispatch('receiveMetricsError', error); - createFlash({ - message: error, - }); - }); -}; diff --git a/app/assets/javascripts/serverless/store/getters.js b/app/assets/javascripts/serverless/store/getters.js deleted file mode 100644 index da975c56e5d..00000000000 --- a/app/assets/javascripts/serverless/store/getters.js +++ /dev/null @@ -1,7 +0,0 @@ -import { translate } from '../utils'; - -export const hasPrometheusMissingData = (state) => state.hasPrometheus && !state.hasPrometheusData; - -// Convert the function list into a k/v grouping based on the environment scope - -export const getFunctions = (state) => translate(state.functions); diff --git a/app/assets/javascripts/serverless/store/index.js b/app/assets/javascripts/serverless/store/index.js deleted file mode 100644 index 6f32d85201e..00000000000 --- a/app/assets/javascripts/serverless/store/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import createState from './state'; - -Vue.use(Vuex); - -export const createStore = (entryPointData = {}) => - new Vuex.Store({ - actions, - getters, - mutations, - state: createState(entryPointData), - }); - -export default createStore; diff --git a/app/assets/javascripts/serverless/store/mutation_types.js b/app/assets/javascripts/serverless/store/mutation_types.js deleted file mode 100644 index b8fa9ea1a01..00000000000 --- a/app/assets/javascripts/serverless/store/mutation_types.js +++ /dev/null @@ -1,11 +0,0 @@ -export const REQUEST_FUNCTIONS_LOADING = 'REQUEST_FUNCTIONS_LOADING'; -export const RECEIVE_FUNCTIONS_SUCCESS = 'RECEIVE_FUNCTIONS_SUCCESS'; -export const RECEIVE_FUNCTIONS_PARTIAL = 'RECEIVE_FUNCTIONS_PARTIAL'; -export const RECEIVE_FUNCTIONS_TIMEOUT = 'RECEIVE_FUNCTIONS_TIMEOUT'; -export const RECEIVE_FUNCTIONS_NODATA_SUCCESS = 'RECEIVE_FUNCTIONS_NODATA_SUCCESS'; -export const RECEIVE_FUNCTIONS_ERROR = 'RECEIVE_FUNCTIONS_ERROR'; - -export const RECEIVE_METRICS_NO_PROMETHEUS = 'RECEIVE_METRICS_NO_PROMETHEUS'; -export const RECEIVE_METRICS_SUCCESS = 'RECEIVE_METRICS_SUCCESS'; -export const RECEIVE_METRICS_NODATA_SUCCESS = 'RECEIVE_METRICS_NODATA_SUCCESS'; -export const RECEIVE_METRICS_ERROR = 'RECEIVE_METRICS_ERROR'; diff --git a/app/assets/javascripts/serverless/store/mutations.js b/app/assets/javascripts/serverless/store/mutations.js deleted file mode 100644 index 2685a5b11ff..00000000000 --- a/app/assets/javascripts/serverless/store/mutations.js +++ /dev/null @@ -1,49 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.REQUEST_FUNCTIONS_LOADING](state) { - state.isLoading = true; - }, - [types.RECEIVE_FUNCTIONS_SUCCESS](state, data) { - state.functions = data.functions; - state.installed = data.knative_installed; - state.isLoading = false; - state.hasFunctionData = true; - }, - [types.RECEIVE_FUNCTIONS_PARTIAL](state, data) { - state.functions = data.functions; - state.installed = true; - state.isLoading = true; - state.hasFunctionData = true; - }, - [types.RECEIVE_FUNCTIONS_TIMEOUT](state) { - state.isLoading = false; - }, - [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state, data) { - state.isLoading = false; - state.installed = data.knative_installed; - state.hasFunctionData = false; - }, - [types.RECEIVE_FUNCTIONS_ERROR](state, error) { - state.error = error; - state.hasFunctionData = false; - state.isLoading = false; - }, - [types.RECEIVE_METRICS_SUCCESS](state, data) { - state.isLoading = false; - state.hasPrometheusData = true; - state.graphData = data; - }, - [types.RECEIVE_METRICS_NODATA_SUCCESS](state) { - state.isLoading = false; - state.hasPrometheusData = false; - }, - [types.RECEIVE_METRICS_ERROR](state, error) { - state.hasPrometheusData = false; - state.error = error; - }, - [types.RECEIVE_METRICS_NO_PROMETHEUS](state) { - state.hasPrometheusData = false; - state.hasPrometheus = false; - }, -}; diff --git a/app/assets/javascripts/serverless/store/state.js b/app/assets/javascripts/serverless/store/state.js deleted file mode 100644 index 353bfcf3fed..00000000000 --- a/app/assets/javascripts/serverless/store/state.js +++ /dev/null @@ -1,22 +0,0 @@ -export default ( - initialState = { clustersPath: null, helpPath: null, emptyImagePath: null, statusPath: null }, -) => ({ - clustersPath: initialState.clustersPath, - error: null, - helpPath: initialState.helpPath, - installed: 'checking', - isLoading: true, - - // functions - functions: [], - hasFunctionData: true, - statusPath: initialState.statusPath, - - // function_details - hasPrometheus: true, - hasPrometheusData: false, - graphData: {}, - - // empty_state - emptyImagePath: initialState.emptyImagePath, -}); diff --git a/app/assets/javascripts/serverless/utils.js b/app/assets/javascripts/serverless/utils.js deleted file mode 100644 index e218a9aa3fd..00000000000 --- a/app/assets/javascripts/serverless/utils.js +++ /dev/null @@ -1,20 +0,0 @@ -// Validate that the object coming in has valid query details and results -export const validateGraphData = (data) => - data.queries && - Array.isArray(data.queries) && - data.queries.filter((query) => { - if (Array.isArray(query.result)) { - return query.result.filter((res) => Array.isArray(res.values)).length === query.result.length; - } - - return false; - }).length === data.queries.length; - -export const translate = (functions) => - functions.reduce( - (acc, func) => - Object.assign(acc, { - [func.environment_scope]: (acc[func.environment_scope] || []).concat([func]), - }), - {}, - ); diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue index 578c344da02..ef40de82d01 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -101,14 +101,15 @@ export default { <template> <!-- must be `d-inline-block` or parent flex-basis causes width issues --> <gl-link - v-gl-tooltip="tooltipOption" :href="assigneeUrl" :title="tooltipTitle" - class="gl-display-inline-block" + :data-user-id="user.id" + data-placement="left" + class="gl-display-inline-block js-user-link" > <!-- use d-flex so that slot can be appropriately styled --> <span class="gl-display-flex"> - <assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> + <assignee-avatar :user="user" :img-size="24" :issuable-type="issuableType" /> <slot></slot> </span> </gl-link> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index f98aa0dc77d..6e18cf36690 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { n__, __ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'AssigneeTitle', @@ -8,6 +9,7 @@ export default { GlLoadingIcon, GlIcon, }, + mixins: [glFeatureFlagMixin()], props: { loading: { type: Boolean, @@ -45,7 +47,7 @@ export default { }; </script> <template> - <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900"> + <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-font-weight-bold"> {{ assigneeTitle }} <gl-loading-icon v-if="loading" size="sm" inline class="align-bottom" /> <a @@ -63,6 +65,7 @@ export default { v-if="showToggle" :aria-label="__('Toggle sidebar')" class="gutter-toggle float-right js-sidebar-toggle" + :class="{ 'gl-display-block gl-md-display-none!': glFeatures.movedMrSidebar }" href="#" role="button" > diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue index 856687c00ae..50b1955abcc 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -124,7 +124,11 @@ export default { :issuable-type="issuableType" /> <button v-if="hasMoreThanTwoAssignees" class="btn-link" type="button"> - <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span> + <span + class="avatar-counter sidebar-avatar-counter gl-display-flex gl-align-items-center gl-pl-3" + > + {{ sidebarAvatarCounter }} + </span> <gl-icon v-if="isMergeRequest && !allAssigneesCanMerge" name="warning-solid" diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue index 19f588b28be..e9c68008143 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -3,6 +3,11 @@ import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui'; import { IssuableType } from '~/issues/constants'; import { s__, sprintf } from '~/locale'; +const AVAILABILITY_STATUS = { + NOT_SET: 'NOT_SET', + BUSY: 'BUSY', +}; + export default { components: { GlAvatarLabeled, @@ -22,12 +27,17 @@ export default { }, computed: { userLabel() { - if (!this.user.status) { - return this.user.name; + const { name, status } = this.user; + if (!status || status?.availability !== AVAILABILITY_STATUS.BUSY) { + return name; } - return sprintf(s__('UserAvailability|%{author} (Busy)'), { - author: this.user.name, - }); + return sprintf( + s__('UserAvailability|%{author} (Busy)'), + { + author: name, + }, + false, + ); }, hasCannotMergeIcon() { return this.issuableType === IssuableType.MergeRequest && !this.user.canMerge; diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index 8717d205dcb..01d29da5486 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -35,13 +35,6 @@ export default { firstUser() { return this.users[0]; }, - hasOneUser() { - if (this.showVerticalList) { - return false; - } - - return this.users.length === 1; - }, hiddenAssigneesLabel() { const { numberOfHiddenAssignees } = this; return sprintf(__('+ %{numberOfHiddenAssignees} more'), { numberOfHiddenAssignees }); @@ -90,30 +83,15 @@ export default { </script> <template> - <assignee-avatar-link - v-if="hasOneUser" - tooltip-placement="left" - :tooltip-has-name="false" - :user="firstUser" - :issuable-type="issuableType" - > - <div class="ml-2 gl-line-height-normal"> - <user-name-with-status :name="firstUser.name" :availability="userAvailability(firstUser)" /> - <div>{{ username }}</div> - </div> - </assignee-avatar-link> - <div v-else> + <div> <div class="gl-display-flex gl-flex-wrap"> <div v-for="(user, index) in uncollapsedUsers" :key="user.id" :class="{ - 'user-item': !showVerticalList, - 'gl-display-inline-block': !showVerticalList, - 'gl-display-grid gl-align-items-center': showVerticalList, - 'gl-mb-3': index !== users.length - 1 && showVerticalList, + 'gl-mb-3': index !== users.length - 1, }" - class="assignee-grid" + class="assignee-grid gl-display-grid gl-align-items-center gl-w-full" > <assignee-avatar-link :user="user" @@ -123,12 +101,10 @@ export default { data-css-area="user" > <div - v-if="showVerticalList" - class="gl-ml-3 gl-line-height-normal gl-display-grid" + class="gl-ml-3 gl-line-height-normal gl-display-grid gl-align-items-center" data-testid="username" > <user-name-with-status :name="user.name" :availability="userAvailability(user)" /> - <span>@{{ user.username }}</span> </div> </assignee-avatar-link> <attention-requested-toggle diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue index cdc1c65a516..031de669489 100644 --- a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue +++ b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue @@ -5,9 +5,8 @@ import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; export default { i18n: { - attentionRequestedReviewer: __('Request attention to review'), - attentionRequestedAssignee: __('Request attention'), - removeAttentionRequested: __('Remove attention request'), + addAttentionRequest: __('Add attention request'), + removeAttentionRequest: __('Remove attention request'), attentionRequestedNoPermission: __('Attention requested'), noAttentionRequestedNoPermission: __('No attention request'), }, @@ -36,20 +35,35 @@ export default { tooltipTitle() { if (this.user.attention_requested) { if (this.user.can_update_merge_request) { - return this.$options.i18n.removeAttentionRequested; + return this.$options.i18n.removeAttentionRequest; } return this.$options.i18n.attentionRequestedNoPermission; } if (this.user.can_update_merge_request) { - return this.type === 'reviewer' - ? this.$options.i18n.attentionRequestedReviewer - : this.$options.i18n.attentionRequestedAssignee; + return this.$options.i18n.addAttentionRequest; } return this.$options.i18n.noAttentionRequestedNoPermission; }, + request() { + const state = { + variant: 'default', + icon: 'attention', + direction: 'add', + }; + + if (this.user.attention_requested) { + Object.assign(state, { + variant: 'warning', + icon: 'attention-solid', + direction: 'remove', + }); + } + + return state; + }, }, methods: { toggleAttentionRequired() { @@ -60,6 +74,7 @@ export default { this.$emit('toggle-attention-requested', { user: this.user, callback: this.toggleAttentionRequiredComplete, + direction: this.request.direction, }); }, toggleAttentionRequiredComplete() { @@ -77,8 +92,8 @@ export default { > <gl-button :loading="loading" - :variant="user.attention_requested ? 'warning' : 'default'" - :icon="user.attention_requested ? 'attention-solid' : 'attention'" + :variant="request.variant" + :icon="request.icon" :aria-label="tooltipTitle" :class="{ 'gl-pointer-events-none': !user.can_update_merge_request }" size="small" diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue index 37a44eb8f01..6afaee91d7a 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue @@ -1,10 +1,13 @@ <script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { GlIcon, GlAlert, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { confidentialityInfoText } from '~/vue_shared/constants'; export default { components: { GlIcon, + GlAlert, }, directives: { GlTooltip: GlTooltipDirective, @@ -20,12 +23,11 @@ export default { }, }, computed: { - confidentialText() { - return this.confidential - ? sprintf(__('This %{issuableType} is confidential'), { - issuableType: this.issuableType, - }) - : __('Not confidential'); + confidentialBodyText() { + return confidentialityInfoText( + this.issuableType === IssuableType.Epic ? WorkspaceType.group : WorkspaceType.project, + this.issuableType, + ); }, confidentialIcon() { return this.confidential ? 'eye-slash' : 'eye'; @@ -59,6 +61,17 @@ export default { class="sidebar-item-icon inline hide-collapsed" :class="{ 'is-active': confidential }" /> - <span class="hide-collapsed" data-testid="confidential-text">{{ confidentialText }}</span> + <span class="hide-collapsed" data-testid="confidential-text"> + {{ tooltipLabel }} + <gl-alert + v-if="confidential" + :show-icon="false" + :dismissible="false" + variant="warning" + class="gl-mt-3" + > + {{ confidentialBodyText }} + </gl-alert> + </span> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue index 209d1cca360..71e40fde77d 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -8,7 +8,7 @@ import { confidentialityQueries } from '~/sidebar/constants'; export default { i18n: { confidentialityOnWarning: __( - 'You are going to turn on confidentiality. Only team members with %{strongStart}at least Reporter access%{strongEnd} will be able to see and leave comments on the %{issuableType}.', + 'You are going to turn on confidentiality. Only %{context} members with %{strongStart}at least Reporter role%{strongEnd} can view or be notified about this %{issuableType}.', ), confidentialityOffWarning: __( 'You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}.', @@ -53,6 +53,9 @@ export default { ? this.$options.i18n.confidentialityOffWarning : this.$options.i18n.confidentialityOnWarning; }, + context() { + return this.issuableType === IssuableType.Issue ? __('project') : __('group'); + }, workspacePath() { return this.issuableType === IssuableType.Issue ? { @@ -119,6 +122,7 @@ export default { <template #strong="{ content }"> <strong>{{ content }}</strong> </template> + <template #context>{{ context }}</template> <template #issuableType>{{ issuableType }}</template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue index 950647f1cb2..67f36f65b5d 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue +++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue @@ -2,7 +2,7 @@ import { GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; import createFlash from '~/flash'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE } from '~/graphql_shared/constants'; import getIssueCrmContactsQuery from './queries/get_issue_crm_contacts.query.graphql'; import issueCrmContactsSubscription from './queries/issue_crm_contacts.subscription.graphql'; @@ -21,6 +21,10 @@ export default { type: String, required: true, }, + groupIssuesPath: { + type: String, + required: true, + }, }, data() { return { @@ -85,6 +89,10 @@ export default { Boolean, ); }, + getIssuesPath(contactId) { + const id = getIdFromGraphQLId(contactId); + return `${this.groupIssuesPath}?crm_contact_id=${id}`; + }, }, }; </script> @@ -100,7 +108,7 @@ export default { ><gl-icon name="question-o" /></gl-link> </div> - <div class="title hide-collapsed gl-mb-2 gl-line-height-20"> + <div class="title hide-collapsed gl-mb-2 gl-line-height-20 gl-font-weight-bold"> {{ contactsLabel }} </div> <div class="hide-collapsed gl-display-flex gl-flex-wrap"> @@ -110,8 +118,8 @@ export default { :key="index" class="gl-pr-2" > - <span :id="`contact_${index}`" class="gl-font-weight-bold" - >{{ contact.firstName }} {{ contact.lastName }}{{ divider(index) }}</span + <gl-link :id="`contact_${index}`" :href="getIssuesPath(contact.id)" + >{{ contact.firstName }} {{ contact.lastName }}{{ divider(index) }}</gl-link > <gl-popover v-if="shouldShowPopover(contact)" diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue b/app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue index 87cf1c29fb0..627b8452508 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue @@ -35,7 +35,7 @@ export default { <template> <div class="gl-display-flex gl-align-items-center hide-collapsed"> <span - :class="hasDate ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'" + :class="hasDate ? 'gl-text-gray-900' : 'gl-text-gray-500'" data-testid="sidebar-date-value" > {{ formattedDate }} diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue index cb49f329f7e..699d1bebea1 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -1,7 +1,9 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; -import { __ } from '~/locale'; +import { mapGetters, mapActions } from 'vuex'; +import { __, sprintf } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import createFlash from '~/flash'; import eventHub from '~/sidebar/event_hub'; import editForm from './edit_form.vue'; @@ -23,11 +25,11 @@ export default { editForm, GlIcon, }, - directives: { GlTooltip: GlTooltipDirective, }, - + mixins: [glFeatureFlagMixin()], + inject: ['fullPath'], props: { isEditable: { required: true, @@ -41,6 +43,9 @@ export default { }, computed: { ...mapGetters(['getNoteableData']), + isMergeRequest() { + return this.getNoteableData.targetType === 'merge_request' && this.glFeatures.movedMrSidebar; + }, issuableDisplayName() { const isInIssuePage = this.getNoteableData.targetType === this.$options.issue; return isInIssuePage ? __('issue') : __('merge request'); @@ -66,17 +71,49 @@ export default { }, methods: { + ...mapActions(['updateLockedAttribute']), toggleForm() { if (this.isEditable) { this.isLockDialogOpen = !this.isLockDialogOpen; } }, + toggleLocked() { + this.isLoading = true; + + this.updateLockedAttribute({ + locked: !this.isLocked, + fullPath: this.fullPath, + }) + .catch(() => { + const flashMessage = __( + 'Something went wrong trying to change the locked state of this %{issuableDisplayName}', + ); + createFlash({ + message: sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName }), + }); + }) + .finally(() => { + this.isLoading = false; + }); + }, }, }; </script> <template> - <div class="block issuable-sidebar-item lock"> + <li v-if="isMergeRequest" class="gl-new-dropdown-item"> + <button type="button" class="dropdown-item" @click="toggleLocked"> + <span class="gl-new-dropdown-item-text-wrapper"> + <template v-if="isLocked"> + {{ __('Unlock merge request') }} + </template> + <template v-else> + {{ __('Lock merge request') }} + </template> + </span> + </button> + </li> + <div v-else class="block issuable-sidebar-item lock"> <div v-gl-tooltip.left.viewport="{ title: tooltipLabel }" class="sidebar-collapsed-icon" @@ -86,7 +123,7 @@ export default { <gl-icon :name="lockStatus.icon" class="sidebar-item-icon is-active" /> </div> - <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900"> + <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-font-weight-bold"> {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }} <a v-if="isEditable" @@ -111,12 +148,6 @@ export default { /> <div data-testid="lock-status" class="sidebar-item-value" :class="lockStatus.class"> - <gl-icon - :size="16" - :name="lockStatus.icon" - class="sidebar-item-icon" - :class="lockStatus.iconClass" - /> {{ lockStatus.displayText }} </div> </div> diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 3fd35de2132..77e41648e9b 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -101,7 +101,10 @@ export default { <gl-loading-icon v-if="loading" size="sm" /> <span v-else data-testid="collapsed-count"> {{ participantCount }} </span> </div> - <div v-if="showParticipantLabel" class="title hide-collapsed gl-mb-2 gl-line-height-20"> + <div + v-if="showParticipantLabel" + class="title hide-collapsed gl-mb-2! gl-line-height-20 gl-font-weight-bold" + > <gl-loading-icon v-if="loading" size="sm" :inline="true" /> {{ participantLabel }} </div> diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue index 60d8fb4d408..e09b5d913f7 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue @@ -96,7 +96,11 @@ export default { <gl-icon v-if="hasNoUsers" name="user" :aria-label="__('None')" /> <collapsed-reviewer v-for="user in collapsedUsers" :key="user.id" :user="user" /> <button v-if="hasMoreThanTwoReviewers" class="btn-link" type="button"> - <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span> + <span + class="avatar-counter sidebar-avatar-counter gl-display-flex gl-align-items-center gl-pl-3" + > + {{ sidebarAvatarCounter }} + </span> <gl-icon v-if="!allReviewersCanMerge" name="warning-solid" diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue index a11468c8761..36a08482e69 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue @@ -47,8 +47,6 @@ export default { return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name }); } else if (this.cannotMerge) { return __('Cannot merge'); - } else if (this.tooltipHasName) { - return this.user.name; } return ''; @@ -70,14 +68,15 @@ export default { <template> <!-- must be `d-inline-block` or parent flex-basis causes width issues --> <gl-link - v-gl-tooltip="tooltipOption" :href="reviewerUrl" :title="tooltipTitle" - class="gl-display-inline-block" + :data-user-id="user.id" + data-placement="left" + class="gl-display-inline-block js-user-link" > <!-- use d-flex so that slot can be appropriately styled --> <span class="gl-display-flex"> - <reviewer-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> + <reviewer-avatar :user="user" :img-size="24" :issuable-type="issuableType" /> <slot :user="user"></slot> </span> </gl-link> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue index 367dcdb961b..933b9b11b40 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue @@ -33,7 +33,7 @@ export default { }; </script> <template> - <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900"> + <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-font-weight-bold"> {{ reviewerTitle }} <gl-loading-icon v-if="loading" size="sm" inline class="align-bottom" /> <a diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue index 3e6be3487b1..2f58e11c00f 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -98,7 +98,7 @@ export default { 'gl-mb-3': index !== users.length - 1, 'attention-requests': glFeatures.mrAttentionRequests, }" - class="gl-display-grid gl-align-items-center reviewer-grid" + class="gl-display-grid gl-align-items-center reviewer-grid gl-mr-2" data-testid="reviewer" > <reviewer-avatar-link @@ -108,9 +108,8 @@ export default { class="gl-word-break-word gl-mr-2" data-css-area="user" > - <div class="gl-ml-3 gl-line-height-normal gl-display-grid"> - <span>{{ user.name }}</span> - <span>@{{ user.username }}</span> + <div class="gl-ml-3 gl-line-height-normal gl-display-grid gl-align-items-center"> + {{ user.name }} </div> </reviewer-avatar-link> <attention-requested-toggle diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index ec23e817127..897cab45fe4 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -331,20 +331,19 @@ export default { :data-testid="`select-${formatIssuableAttribute.kebab}`" :class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'" > - <span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span> + <span v-if="updating">{{ selectedTitle }}</span> <span v-else-if="!currentAttribute" class="gl-text-gray-500"> {{ $options.i18n.none }} </span> <slot v-else name="value" - :attributeTitle="attributeTitle" - :attributeUrl="attributeUrl" - :currentAttribute="currentAttribute" + :attribute-title="attributeTitle" + :attribute-url="attributeUrl" + :current-attribute="currentAttribute" > <gl-link v-gl-tooltip="tooltipText" - class="gl-text-gray-900! gl-font-weight-bold" :href="attributeUrl" :data-qa-selector="`${formatIssuableAttribute.snake}_link`" > @@ -389,9 +388,9 @@ export default { <slot v-else name="list" - :attributesList="attributesList" - :isAttributeChecked="isAttributeChecked" - :updateAttribute="updateAttribute" + :attributes-list="attributesList" + :is-attribute-checked="isAttributeChecked" + :update-attribute="updateAttribute" > <gl-dropdown-item v-for="attrItem in attributesList" diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index 056b3e98a1c..7551b181a58 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -110,7 +110,7 @@ export default { <template> <div> <div - class="gl-display-flex gl-align-items-center gl-line-height-20 gl-mb-2 gl-text-gray-900" + class="gl-display-flex gl-align-items-center gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-font-weight-bold" @click.self="collapse" > <span class="hide-collapsed" data-testid="title" @click="collapse"> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue index 7a10a9f3a4c..1bafa845665 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -5,6 +5,7 @@ import { IssuableType } from '~/issues/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __, sprintf } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { subscribedQueries, Tracking } from '~/sidebar/constants'; const ICON_ON = 'notifications'; @@ -25,6 +26,7 @@ export default { GlToggle, SidebarEditableItem, }, + mixins: [glFeatureFlagMixin()], props: { iid: { type: String, @@ -82,6 +84,9 @@ export default { }, }, computed: { + isMergeRequest() { + return this.issuableType === IssuableType.MergeRequest && this.glFeatures.movedMrSidebar; + }, isLoading() { return this.$apollo.queries?.subscribed?.loading || this.loading; }, @@ -171,7 +176,20 @@ export default { </script> <template> + <li v-if="isMergeRequest" class="gl-new-dropdown-item"> + <button type="button" class="dropdown-item" @click="toggleSubscribed"> + <span class="gl-new-dropdown-item-text-wrapper"> + <template v-if="subscribed"> + {{ __('Turn off notifications') }} + </template> + <template v-else> + {{ __('Turn on notifications') }} + </template> + </span> + </button> + </li> <sidebar-editable-item + v-else ref="editable" :title="$options.i18n.notifications" :tracking="$options.tracking" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue index 5d4031ac68b..d9797961d40 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -78,9 +78,9 @@ export default { }, }, fields: [ - { key: 'spentAt', label: __('Spent At'), sortable: true }, + { key: 'spentAt', label: __('Spent At'), sortable: true, tdClass: 'gl-w-quarter' }, { key: 'user', label: __('User'), sortable: true }, - { key: 'timeSpent', label: __('Time Spent'), sortable: true }, + { key: 'timeSpent', label: __('Time Spent'), sortable: true, tdClass: 'gl-w-15' }, { key: 'summary', label: __('Summary / Note'), sortable: true }, ], }; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index fdbcef22bba..057bb9f0100 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -204,7 +204,7 @@ export default { :time-estimate-human-readable="humanTimeEstimate" /> <div - class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center" + class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center gl-font-weight-bold gl-mr-3" > {{ __('Time tracking') }} <gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" class="gl-ml-2" inline /> @@ -221,8 +221,7 @@ export default { </div> <div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed"> <div v-if="showEstimateOnlyState" data-testid="estimateOnlyPane"> - <span class="gl-font-weight-bold">{{ $options.i18n.estimatedOnlyText }} </span - >{{ humanTimeEstimate }} + <span>{{ $options.i18n.estimatedOnlyText }} </span>{{ humanTimeEstimate }} </div> <time-tracking-spent-only-pane v-if="showSpentOnlyState" @@ -250,6 +249,7 @@ export default { </gl-link> <gl-modal modal-id="time-tracking-report" + size="lg" :title="__('Time tracking report')" :hide-footer="true" > diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue index eabba619af5..482b9343e70 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue @@ -6,6 +6,9 @@ import { __, sprintf } from '~/locale'; import { todoQueries, TodoMutationTypes, todoMutations } from '~/sidebar/constants'; import { todoLabel } from '~/vue_shared/components/sidebar/todo_toggle//utils'; import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; +import Tracking from '~/tracking'; + +const trackingMixin = Tracking.mixin(); export default { components: { @@ -16,6 +19,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [trackingMixin], inject: { isClassicSidebar: { default: false, @@ -151,6 +155,10 @@ export default { message: errors[0], }); } + this.track('click_todo', { + label: 'right_sidebar', + property: this.hasTodo, + }); }, ) .catch(() => { diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 2a7d967cb61..351bb50d941 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -218,7 +218,7 @@ function mountCrmContactsComponent() { if (!el) return; - const { issueId } = el.dataset; + const { issueId, groupIssuesPath } = el.dataset; // eslint-disable-next-line no-new new Vue({ el, @@ -231,6 +231,7 @@ function mountCrmContactsComponent() { createElement('crm-contacts', { props: { issueId, + groupIssuesPath, }, }), }); @@ -430,10 +431,7 @@ function mountLockComponent(store) { return; } - const { fullPath } = getSidebarOptions(); - - const dataNode = document.getElementById('js-lock-issue-data'); - const initialData = JSON.parse(dataNode.innerHTML); + const { fullPath, editable } = getSidebarOptions(); // eslint-disable-next-line no-new new Vue({ @@ -446,7 +444,7 @@ function mountLockComponent(store) { render: (createElement) => createElement(IssuableLockForm, { props: { - isEditable: initialData.is_editable, + isEditable: editable, }, }), }); diff --git a/app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql b/app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql new file mode 100644 index 00000000000..d9b9c04fd63 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql @@ -0,0 +1,7 @@ +mutation mergeRequestRemoveAttentionRequest($projectPath: ID!, $iid: String!, $userId: UserID!) { + mergeRequestRemoveAttentionRequest( + input: { projectPath: $projectPath, iid: $iid, userId: $userId } + ) { + errors + } +} diff --git a/app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql b/app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql new file mode 100644 index 00000000000..99a86e4fe5c --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql @@ -0,0 +1,5 @@ +mutation mergeRequestRequestAttention($projectPath: ID!, $iid: String!, $userId: UserID!) { + mergeRequestRequestAttention(input: { projectPath: $projectPath, iid: $iid, userId: $userId }) { + errors + } +} diff --git a/app/assets/javascripts/sidebar/queries/reviewer_rereview.mutation.graphql b/app/assets/javascripts/sidebar/queries/reviewer_rereview.mutation.graphql index 73765e7d77b..0d66ee0d6e5 100644 --- a/app/assets/javascripts/sidebar/queries/reviewer_rereview.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/reviewer_rereview.mutation.graphql @@ -1,4 +1,4 @@ -mutation mergeRequestRequestRereview($projectPath: ID!, $iid: String!, $userId: ID!) { +mutation mergeRequestRequestRereview($projectPath: ID!, $iid: String!, $userId: UserID!) { mergeRequestReviewerRereview(input: { projectPath: $projectPath, iid: $iid, userId: $userId }) { errors } diff --git a/app/assets/javascripts/sidebar/queries/toggle_attention_requested.mutation.graphql b/app/assets/javascripts/sidebar/queries/toggle_attention_requested.mutation.graphql deleted file mode 100644 index a9f4af6e1b9..00000000000 --- a/app/assets/javascripts/sidebar/queries/toggle_attention_requested.mutation.graphql +++ /dev/null @@ -1,7 +0,0 @@ -mutation mergeRequestToggleAttentionRequested($projectPath: ID!, $iid: String!, $userId: ID!) { - mergeRequestToggleAttentionRequested( - input: { projectPath: $projectPath, iid: $iid, userId: $userId } - ) { - errors - } -} diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql index 368f06fac7f..938953ccfb2 100644 --- a/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql @@ -1,4 +1,4 @@ -mutation mergeRequestSetMilestone($fullPath: ID!, $iid: String!, $attributeId: ID) { +mutation mergeRequestSetMilestone($fullPath: ID!, $iid: String!, $attributeId: MilestoneID) { issuableSetAttribute: mergeRequestSetMilestone( input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId } ) { diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index 90d8f2098bb..ea170203576 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -5,7 +5,8 @@ import createGqClient, { fetchPolicies } from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql'; import sidebarDetailsMRQuery from '../queries/sidebar_details_mr.query.graphql'; -import toggleAttentionRequestedMutation from '../queries/toggle_attention_requested.mutation.graphql'; +import requestAttentionMutation from '../queries/request_attention.mutation.graphql'; +import removeAttentionRequestMutation from '../queries/remove_attention_request.mutation.graphql'; const queries = { merge_request: sidebarDetailsMRQuery, @@ -92,9 +93,19 @@ export default class SidebarService { }); } - toggleAttentionRequested(userId) { + requestAttention(userId) { return gqClient.mutate({ - mutation: toggleAttentionRequestedMutation, + mutation: requestAttentionMutation, + variables: { + userId: convertToGraphQLId(TYPE_USER, `${userId}`), + projectPath: this.fullPath, + iid: this.iid.toString(), + }, + }); + } + removeAttentionRequest(userId) { + return gqClient.mutate({ + mutation: removeAttentionRequestMutation, variables: { userId: convertToGraphQLId(TYPE_USER, `${userId}`), projectPath: this.fullPath, diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 83fb8f31dfb..7df901577b8 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -40,6 +40,7 @@ export default class SidebarMediator { const data = { assignee_ids: assignees }; try { + const { currentUserHasAttention } = this.store; const res = await this.service.update(field, data); this.store.overwrite('assignees', res.data.assignees); @@ -48,6 +49,10 @@ export default class SidebarMediator { this.store.overwrite('reviewers', res.data.reviewers); } + if (currentUserHasAttention && this.store.isAddingAssignee) { + toast(__('Assigned user(s). Your attention request was removed.')); + } + return Promise.resolve(res); } catch (e) { return Promise.reject(e); @@ -63,11 +68,16 @@ export default class SidebarMediator { const data = { reviewer_ids: reviewers }; try { + const { currentUserHasAttention } = this.store; const res = await this.service.update(field, data); this.store.overwrite('reviewers', res.data.reviewers); this.store.overwrite('assignees', res.data.assignees); + if (currentUserHasAttention && this.store.isAddingAssignee) { + toast(__('Requested review. Your attention request was removed.')); + } + return Promise.resolve(res); } catch (e) { return Promise.reject(); @@ -98,14 +108,19 @@ export default class SidebarMediator { } } - async toggleAttentionRequested(type, { user, callback }) { + async toggleAttentionRequested(type, { user, callback, direction }) { + const mutations = { + add: (id) => this.service.requestAttention(id), + remove: (id) => this.service.removeAttentionRequest(id), + }; + try { const isReviewer = type === 'reviewer'; const reviewerOrAssignee = isReviewer ? this.store.findReviewer(user) : this.store.findAssignee(user); - await this.service.toggleAttentionRequested(user.id); + await mutations[direction]?.(user.id); if (reviewerOrAssignee.attention_requested) { toast( @@ -115,12 +130,22 @@ export default class SidebarMediator { ); } else { const currentUserId = gon.current_user_id; + const { currentUserHasAttention } = this.store; if (currentUserId !== user.id) { this.removeCurrentUserAttentionRequested(); } - toast(sprintf(__('Requested attention from @%{username}'), { username: user.username })); + toast( + currentUserHasAttention && currentUserId !== user.id + ? sprintf( + __( + 'Requested attention from @%{username}. Your own attention request was removed.', + ), + { username: user.username }, + ) + : sprintf(__('Requested attention from @%{username}'), { username: user.username }), + ); } this.store.updateReviewer(user.id, 'attention_requested'); @@ -138,7 +163,7 @@ export default class SidebarMediator { captureError: true, actionConfig: { title: __('Try again'), - clickHandler: () => this.toggleAttentionRequired(type, { user, callback }), + clickHandler: () => this.toggleAttentionRequired(type, { user, callback, direction }), }, }); } diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 2caa6f4f0a0..ca85ee7fd94 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -18,7 +18,9 @@ export default class SidebarStore { this.humanTimeSpent = ''; this.timeTrackingLimitToHours = timeTrackingLimitToHours; this.assignees = []; + this.addingAssignees = []; this.reviewers = []; + this.addingReviewers = []; this.isFetching = { assignees: true, reviewers: true, @@ -32,6 +34,7 @@ export default class SidebarStore { this.subscribeDisabledDescription = ''; this.subscribed = null; this.changing = false; + this.issuableType = options.issuableType; SidebarStore.singleton = this; } @@ -73,12 +76,20 @@ export default class SidebarStore { if (!this.findAssignee(assignee)) { this.changing = true; this.assignees.push(assignee); + + if (assignee.id !== this.currentUser.id) { + this.addingAssignees.push(assignee.id); + } } } addReviewer(reviewer) { if (!this.findReviewer(reviewer)) { this.reviewers.push(reviewer); + + if (reviewer.id !== this.currentUser.id) { + this.addingReviewers.push(reviewer.id); + } } } @@ -114,12 +125,14 @@ export default class SidebarStore { if (assignee) { this.changing = true; this.assignees = this.assignees.filter(({ id }) => id !== assignee.id); + this.addingAssignees = this.addingAssignees.filter(({ id }) => id !== assignee.id); } } removeReviewer(reviewer) { if (reviewer) { this.reviewers = this.reviewers.filter(({ id }) => id !== reviewer.id); + this.addingReviewers = this.addingReviewers.filter(({ id }) => id !== reviewer.id); } } @@ -147,4 +160,26 @@ export default class SidebarStore { setMoveToProjectId(moveToProjectId) { this.moveToProjectId = moveToProjectId; } + + get currentUserHasAttention() { + if (!window.gon?.features?.mrAttentionRequests || !this.isMergeRequest) return false; + + const currentUserId = this.currentUser.id; + const currentUserReviewer = this.findReviewer({ id: currentUserId }); + const currentUserAssignee = this.findAssignee({ id: currentUserId }); + + return currentUserReviewer?.attention_requested || currentUserAssignee?.attention_requested; + } + + get isAddingAssignee() { + return this.addingAssignees.length > 0; + } + + get isAddingReviewer() { + return this.addingReviewers.length > 0; + } + + get isMergeRequest() { + return this.issuableType === 'merge_request'; + } } diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index e4a97f08c8d..2537ec78850 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlFormInput, GlFormGroup } from '@gitlab/ui'; import eventHub from '~/blob/components/eventhub'; import createFlash from '~/flash'; @@ -11,7 +11,6 @@ import { } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; -import TitleField from '~/vue_shared/components/form/title.vue'; import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '../constants'; import { getSnippetMixin } from '../mixins/snippets'; @@ -31,10 +30,11 @@ export default { SnippetDescriptionEdit, SnippetVisibilityEdit, SnippetBlobActionsEdit, - TitleField, FormFooterActions, GlButton, GlLoadingIcon, + GlFormInput, + GlFormGroup, }, mixins: [getSnippetMixin], inject: ['selectedLevel'], @@ -67,6 +67,7 @@ export default { description: '', visibilityLevel: this.selectedLevel, }, + showValidation: false, }; }, computed: { @@ -85,8 +86,11 @@ export default { hasValidBlobs() { return this.actions.every((x) => x.content); }, - updatePrevented() { - return this.snippet.title === '' || !this.hasValidBlobs || this.isUpdating; + isTitleValid() { + return this.snippet.title !== ''; + }, + isFormValid() { + return this.isTitleValid && this.hasValidBlobs; }, isProjectSnippet() { return Boolean(this.projectPath); @@ -112,6 +116,12 @@ export default { } return this.snippet.webUrl; }, + shouldShowBlobsErrors() { + return this.showValidation && !this.hasValidBlobs; + }, + shouldShowTitleErrors() { + return this.showValidation && !this.isTitleValid; + }, }, beforeCreate() { performanceMarkAndMeasure({ mark: SNIPPET_MARK_EDIT_APP_START }); @@ -165,6 +175,12 @@ export default { }; }, handleFormSubmit() { + this.showValidation = true; + + if (!this.isFormValid) { + return; + } + this.isUpdating = true; this.$apollo @@ -206,19 +222,31 @@ export default { class="loading-animation prepend-top-20 gl-mb-6" /> <template v-else> - <title-field - id="snippet-title" - v-model="snippet.title" - data-qa-selector="snippet_title_field" - required - :autofocus="true" - /> + <gl-form-group + :label="__('Title')" + label-for="snippet-title" + :invalid-feedback="__('This field is required.')" + :state="!shouldShowTitleErrors" + > + <gl-form-input + id="snippet-title" + v-model="snippet.title" + data-testid="snippet-title-input" + data-qa-selector="snippet_title_field" + :autofocus="true" + /> + </gl-form-group> + <snippet-description-edit v-model="snippet.description" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" /> - <snippet-blob-actions-edit :init-blobs="blobs" @actions="updateActions" /> + <snippet-blob-actions-edit + :init-blobs="blobs" + :is-valid="!shouldShowBlobsErrors" + @actions="updateActions" + /> <snippet-visibility-edit v-model="snippet.visibilityLevel" @@ -228,12 +256,13 @@ export default { <form-footer-actions> <template #prepend> <gl-button + class="js-no-auto-disable" category="primary" type="submit" variant="confirm" - :disabled="updatePrevented" data-qa-selector="submit_button" data-testid="snippet-submit-btn" + :disabled="isUpdating" >{{ saveButtonLabel }}</gl-button > </template> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue index d221195ddc7..260ee496df0 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue @@ -1,5 +1,5 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlFormGroup } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import { s__, sprintf } from '~/locale'; import { SNIPPET_MAX_BLOBS } from '../constants'; @@ -10,12 +10,18 @@ export default { components: { SnippetBlobEdit, GlButton, + GlFormGroup, }, props: { initBlobs: { type: Array, required: true, }, + isValid: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -124,16 +130,26 @@ export default { </script> <template> <div class="form-group"> - <label :for="firstInputId">{{ s__('Snippets|Files') }}</label> - <snippet-blob-edit - v-for="(blobId, index) in blobIds" - :key="blobId" - :class="{ 'gl-mt-3': index > 0 }" - :blob="blobs[blobId]" - :can-delete="canDelete" - @blob-updated="updateBlob(blobId, $event)" - @delete="deleteBlob(blobId)" - /> + <gl-form-group + :label="s__('Snippets|Files')" + :label-for="firstInputId" + :invalid-feedback=" + s__( + 'Snippets|Snippets can\'t contain empty files. Ensure all files have content, or delete them.', + ) + " + :state="isValid" + > + <snippet-blob-edit + v-for="(blobId, index) in blobIds" + :key="blobId" + :class="{ 'gl-mt-3': index > 0 }" + :blob="blobs[blobId]" + :can-delete="canDelete" + @blob-updated="updateBlob(blobId, $event)" + @delete="deleteBlob(blobId)" + /> + </gl-form-group> <gl-button :disabled="!canAdd" data-testid="add_button" diff --git a/app/assets/javascripts/snippets/mutations/delete_snippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/delete_snippet.mutation.graphql index f43d53661f4..a13c143f775 100644 --- a/app/assets/javascripts/snippets/mutations/delete_snippet.mutation.graphql +++ b/app/assets/javascripts/snippets/mutations/delete_snippet.mutation.graphql @@ -1,4 +1,4 @@ -mutation DeleteSnippet($id: ID!) { +mutation DeleteSnippet($id: SnippetID!) { destroySnippet(input: { id: $id }) { errors } diff --git a/app/assets/javascripts/sortable/constants.js b/app/assets/javascripts/sortable/constants.js index 7fddac00ab2..f5bb0a3b11f 100644 --- a/app/assets/javascripts/sortable/constants.js +++ b/app/assets/javascripts/sortable/constants.js @@ -1,3 +1,5 @@ +export const DRAG_CLASS = 'is-dragging'; + /** * Default config options for sortablejs. * @type {object} @@ -12,7 +14,7 @@ export const defaultSortableOptions = { animation: 200, forceFallback: true, - fallbackClass: 'is-dragging', + fallbackClass: DRAG_CLASS, fallbackOnBody: true, ghostClass: 'is-ghost', fallbackTolerance: 1, diff --git a/app/assets/javascripts/sortable/utils.js b/app/assets/javascripts/sortable/utils.js index c2c8fb03b58..88ac1295a39 100644 --- a/app/assets/javascripts/sortable/utils.js +++ b/app/assets/javascripts/sortable/utils.js @@ -1,13 +1,17 @@ /* global DocumentTouch */ -import { defaultSortableOptions } from './constants'; +import { defaultSortableOptions, DRAG_CLASS } from './constants'; export function sortableStart() { - document.body.classList.add('is-dragging'); + document.body.classList.add(DRAG_CLASS); } export function sortableEnd() { - document.body.classList.remove('is-dragging'); + document.body.classList.remove(DRAG_CLASS); +} + +export function isDragging() { + return document.body.classList.contains(DRAG_CLASS); } export function getSortableDefaultOptions(options) { diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index ea775eff358..2f2efe290ec 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -167,7 +167,9 @@ export default { :content="editableContent" :initial-edit-type="editorMode" :image-root="imageRoot" - :options="{ customRenderers }" + :options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + customRenderers, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" class="mb-9 pb-6 h-100" @modeChange="onModeChange" @input="onInputChange" diff --git a/app/assets/javascripts/tracking/dispatch_snowplow_event.js b/app/assets/javascripts/tracking/dispatch_snowplow_event.js index 7e596f5f36f..5daeaf1d85b 100644 --- a/app/assets/javascripts/tracking/dispatch_snowplow_event.js +++ b/app/assets/javascripts/tracking/dispatch_snowplow_event.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/browser'; import getStandardContext from './get_standard_context'; export function dispatchSnowplowEvent( @@ -24,5 +25,11 @@ export function dispatchSnowplowEvent( value = Number(value); } - return window.snowplow('trackStructEvent', category, action, label, property, value, contexts); + try { + window.snowplow('trackStructEvent', category, action, label, property, value, contexts); + return true; + } catch (error) { + Sentry.captureException(error); + return false; + } } diff --git a/app/assets/javascripts/tracking/tracker.js b/app/assets/javascripts/tracking/tracker.js new file mode 100644 index 00000000000..9ad86e76b6e --- /dev/null +++ b/app/assets/javascripts/tracking/tracker.js @@ -0,0 +1,267 @@ +import { LOAD_ACTION_ATTR_SELECTOR } from './constants'; +import { dispatchSnowplowEvent } from './dispatch_snowplow_event'; +import getStandardContext from './get_standard_context'; +import { + getEventHandlers, + createEventPayload, + renameKey, + getReferrersCache, + addReferrersCacheEntry, +} from './utils'; + +export const Tracker = { + nonInitializedQueue: [], + initialized: false, + definitionsLoaded: false, + definitionsManifest: {}, + definitionsEventsQueue: [], + definitions: [], + ALLOWED_URL_HASHES: ['#diff', '#note'], + /** + * (Legacy) Determines if tracking is enabled at the user level. + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/DNT. + * + * @returns {Boolean} + */ + trackable() { + return !['1', 'yes'].includes( + window.doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack, + ); + }, + + /** + * Determines if Snowplow is available/enabled. + * + * @returns {Boolean} + */ + enabled() { + return typeof window.snowplow === 'function' && Tracker.trackable(); + }, + + /** + * Dispatches a structured event per our taxonomy: + * https://docs.gitlab.com/ee/development/snowplow/index.html#structured-event-taxonomy. + * + * If the library is not initialized and events are trying to be + * dispatched (data-attributes, load-events), they will be added + * to a queue to be flushed afterwards. + * + * If there is an error when using the library, it will return ´false´ + * and ´true´ otherwise. + * + * @param {...any} eventData defined event taxonomy + * @returns {Boolean} + */ + event(...eventData) { + if (!Tracker.enabled()) { + return false; + } + + if (!Tracker.initialized) { + Tracker.nonInitializedQueue.push(eventData); + return false; + } + + return dispatchSnowplowEvent(...eventData); + }, + + /** + * Preloads event definitions. + * + * @returns {undefined} + */ + loadDefinitions() { + // TODO: fetch definitions from the server and flush the queue + // See https://gitlab.com/gitlab-org/gitlab/-/issues/358256 + Tracker.definitionsLoaded = true; + + while (Tracker.definitionsEventsQueue.length) { + Tracker.dispatchFromDefinition(...Tracker.definitionsEventsQueue.shift()); + } + }, + + /** + * Dispatches a structured event with data from its event definition. + * + * @param {String} basename + * @param {Object} eventData + * @returns {Boolean} + */ + definition(basename, eventData = {}) { + if (!Tracker.enabled()) { + return false; + } + + if (!(basename in Tracker.definitionsManifest)) { + throw new Error(`Missing Snowplow event definition "${basename}"`); + } + + return Tracker.dispatchFromDefinition(basename, eventData); + }, + + /** + * Builds an event with data from a valid definition and sends it to + * Snowplow. If the definitions are not loaded, it pushes the data to a queue. + * + * @param {String} basename + * @param {Object} eventData + * @returns {Boolean} + */ + dispatchFromDefinition(basename, eventData) { + if (!Tracker.definitionsLoaded) { + Tracker.definitionsEventsQueue.push([basename, eventData]); + + return false; + } + + const eventDefinition = Tracker.definitions.find((definition) => definition.key === basename); + + return Tracker.event( + eventData.category ?? eventDefinition.category, + eventData.action ?? eventDefinition.action, + eventData, + ); + }, + + /** + * Dispatches any event emitted before initialization. + * + * @returns {undefined} + */ + flushPendingEvents() { + Tracker.initialized = true; + + while (Tracker.nonInitializedQueue.length) { + dispatchSnowplowEvent(...Tracker.nonInitializedQueue.shift()); + } + }, + + /** + * Attaches event handlers for data-attributes powered events. + * + * @param {String} category - the default category for all events + * @param {HTMLElement} parent - element containing data-attributes + * @returns {Array} + */ + bindDocument(category = document.body.dataset.page, parent = document) { + if (!Tracker.enabled() || parent.trackingBound) { + return []; + } + + // eslint-disable-next-line no-param-reassign + parent.trackingBound = true; + + const handlers = getEventHandlers(category, (...args) => Tracker.event(...args)); + handlers.forEach((event) => parent.addEventListener(event.name, event.func)); + + return handlers; + }, + + /** + * Attaches event handlers for load-events (on render). + * + * @param {String} category - the default category for all events + * @param {HTMLElement} parent - element containing event targets + * @returns {Array} + */ + trackLoadEvents(category = document.body.dataset.page, parent = document) { + if (!Tracker.enabled()) { + return []; + } + + const loadEvents = parent.querySelectorAll(LOAD_ACTION_ATTR_SELECTOR); + + loadEvents.forEach((element) => { + const { action, data } = createEventPayload(element); + Tracker.event(category, action, data); + }); + + return loadEvents; + }, + + /** + * Enable Snowplow automatic form tracking. + * The config param requires at least one array of either forms + * class names, or field name attributes. + * https://docs.gitlab.com/ee/development/snowplow/index.html#form-tracking. + * + * @param {Object} config + * @param {Array} contexts + * @returns {undefined} + */ + enableFormTracking(config, contexts = []) { + if (!Tracker.enabled()) { + return; + } + + if (!Array.isArray(config?.forms?.allow) && !Array.isArray(config?.fields?.allow)) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Unable to enable form event tracking without allow rules.'); + } + + // Ignore default/standard schema + const standardContext = getStandardContext(); + const userProvidedContexts = contexts.filter( + (context) => context.schema !== standardContext.schema, + ); + + const mappedConfig = {}; + if (config.forms) { + mappedConfig.forms = renameKey(config.forms, 'allow', 'whitelist'); + } + + if (config.fields) { + mappedConfig.fields = renameKey(config.fields, 'allow', 'whitelist'); + } + + const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts); + + if (document.readyState === 'complete') { + enabler(); + } else { + document.addEventListener('readystatechange', () => { + if (document.readyState === 'complete') { + enabler(); + } + }); + } + }, + + /** + * Replaces the URL and referrer for the default web context + * if the replacements are available. + * + * @returns {undefined} + */ + setAnonymousUrls() { + const { snowplowPseudonymizedPageUrl: pageUrl } = window.gl; + + if (!pageUrl) { + return; + } + + const referrers = getReferrersCache(); + const pageLinks = Object.seal({ + url: pageUrl, + referrer: '', + originalUrl: window.location.href, + }); + + const appendHash = Tracker.ALLOWED_URL_HASHES.some((prefix) => + window.location.hash.startsWith(prefix), + ); + const customUrl = `${pageUrl}${appendHash ? window.location.hash : ''}`; + window.snowplow('setCustomUrl', customUrl); + + if (document.referrer) { + const node = referrers.find((links) => links.originalUrl === document.referrer); + + if (node) { + pageLinks.referrer = node.url; + window.snowplow('setReferrerUrl', pageLinks.referrer); + } + } + + addReferrersCacheEntry(referrers, pageLinks); + }, +}; diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js index f299c57b33f..923aea433f1 100644 --- a/app/assets/javascripts/tracking/tracking.js +++ b/app/assets/javascripts/tracking/tracking.js @@ -1,268 +1,7 @@ -import { LOAD_ACTION_ATTR_SELECTOR } from './constants'; -import { dispatchSnowplowEvent } from './dispatch_snowplow_event'; -import getStandardContext from './get_standard_context'; -import { - getEventHandlers, - createEventPayload, - renameKey, - addExperimentContext, - getReferrersCache, - addReferrersCacheEntry, -} from './utils'; - -const ALLOWED_URL_HASHES = ['#diff', '#note']; - -export default class Tracking { - static nonInitializedQueue = []; - static initialized = false; - static definitionsLoaded = false; - static definitionsManifest = {}; - static definitionsEventsQueue = []; - static definitions = []; - - /** - * (Legacy) Determines if tracking is enabled at the user level. - * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/DNT. - * - * @returns {Boolean} - */ - static trackable() { - return !['1', 'yes'].includes( - window.doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack, - ); - } - - /** - * Determines if Snowplow is available/enabled. - * - * @returns {Boolean} - */ - static enabled() { - return typeof window.snowplow === 'function' && this.trackable(); - } - - /** - * Dispatches a structured event per our taxonomy: - * https://docs.gitlab.com/ee/development/snowplow/index.html#structured-event-taxonomy. - * - * If the library is not initialized and events are trying to be - * dispatched (data-attributes, load-events), they will be added - * to a queue to be flushed afterwards. - * - * @param {...any} eventData defined event taxonomy - * @returns {undefined|Boolean} - */ - static event(...eventData) { - if (!this.enabled()) { - return false; - } - - if (!this.initialized) { - this.nonInitializedQueue.push(eventData); - return false; - } - - return dispatchSnowplowEvent(...eventData); - } - - /** - * Preloads event definitions. - * - * @returns {undefined} - */ - static loadDefinitions() { - // TODO: fetch definitions from the server and flush the queue - // See https://gitlab.com/gitlab-org/gitlab/-/issues/358256 - this.definitionsLoaded = true; - - while (this.definitionsEventsQueue.length) { - this.dispatchFromDefinition(...this.definitionsEventsQueue.shift()); - } - } - - /** - * Dispatches a structured event with data from its event definition. - * - * @param {String} basename - * @param {Object} eventData - * @returns {undefined|Boolean} - */ - static definition(basename, eventData = {}) { - if (!this.enabled()) { - return false; - } - - if (!(basename in this.definitionsManifest)) { - throw new Error(`Missing Snowplow event definition "${basename}"`); - } - - return this.dispatchFromDefinition(basename, eventData); - } - - /** - * Builds an event with data from a valid definition and sends it to - * Snowplow. If the definitions are not loaded, it pushes the data to a queue. - * - * @param {String} basename - * @param {Object} eventData - * @returns {undefined|Boolean} - */ - static dispatchFromDefinition(basename, eventData) { - if (!this.definitionsLoaded) { - this.definitionsEventsQueue.push([basename, eventData]); - - return false; - } - - const eventDefinition = this.definitions.find((definition) => definition.key === basename); - - return this.event( - eventData.category ?? eventDefinition.category, - eventData.action ?? eventDefinition.action, - eventData, - ); - } - - /** - * Dispatches any event emitted before initialization. - * - * @returns {undefined} - */ - static flushPendingEvents() { - this.initialized = true; - - while (this.nonInitializedQueue.length) { - dispatchSnowplowEvent(...this.nonInitializedQueue.shift()); - } - } - - /** - * Attaches event handlers for data-attributes powered events. - * - * @param {String} category - the default category for all events - * @param {HTMLElement} parent - element containing data-attributes - * @returns {Array} - */ - static bindDocument(category = document.body.dataset.page, parent = document) { - if (!this.enabled() || parent.trackingBound) { - return []; - } - - // eslint-disable-next-line no-param-reassign - parent.trackingBound = true; - - const handlers = getEventHandlers(category, (...args) => this.event(...args)); - handlers.forEach((event) => parent.addEventListener(event.name, event.func)); - - return handlers; - } - - /** - * Attaches event handlers for load-events (on render). - * - * @param {String} category - the default category for all events - * @param {HTMLElement} parent - element containing event targets - * @returns {Array} - */ - static trackLoadEvents(category = document.body.dataset.page, parent = document) { - if (!this.enabled()) { - return []; - } - - const loadEvents = parent.querySelectorAll(LOAD_ACTION_ATTR_SELECTOR); - - loadEvents.forEach((element) => { - const { action, data } = createEventPayload(element); - this.event(category, action, data); - }); - - return loadEvents; - } - - /** - * Enable Snowplow automatic form tracking. - * The config param requires at least one array of either forms - * class names, or field name attributes. - * https://docs.gitlab.com/ee/development/snowplow/index.html#form-tracking. - * - * @param {Object} config - * @param {Array} contexts - * @returns {undefined} - */ - static enableFormTracking(config, contexts = []) { - if (!this.enabled()) { - return; - } - - if (!Array.isArray(config?.forms?.allow) && !Array.isArray(config?.fields?.allow)) { - // eslint-disable-next-line @gitlab/require-i18n-strings - throw new Error('Unable to enable form event tracking without allow rules.'); - } - - // Ignore default/standard schema - const standardContext = getStandardContext(); - const userProvidedContexts = contexts.filter( - (context) => context.schema !== standardContext.schema, - ); - - const mappedConfig = {}; - if (config.forms) { - mappedConfig.forms = renameKey(config.forms, 'allow', 'whitelist'); - } - - if (config.fields) { - mappedConfig.fields = renameKey(config.fields, 'allow', 'whitelist'); - } - - const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts); - - if (document.readyState === 'complete') { - enabler(); - } else { - document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') { - enabler(); - } - }); - } - } - - /** - * Replaces the URL and referrer for the default web context - * if the replacements are available. - * - * @returns {undefined} - */ - static setAnonymousUrls() { - const { snowplowPseudonymizedPageUrl: pageUrl } = window.gl; - - if (!pageUrl) { - return; - } - - const referrers = getReferrersCache(); - const pageLinks = Object.seal({ - url: pageUrl, - referrer: '', - originalUrl: window.location.href, - }); - - const appendHash = ALLOWED_URL_HASHES.some((prefix) => window.location.hash.startsWith(prefix)); - const customUrl = `${pageUrl}${appendHash ? window.location.hash : ''}`; - window.snowplow('setCustomUrl', customUrl); - - if (document.referrer) { - const node = referrers.find((links) => links.originalUrl === document.referrer); - - if (node) { - pageLinks.referrer = node.url; - window.snowplow('setReferrerUrl', pageLinks.referrer); - } - } - - addReferrersCacheEntry(referrers, pageLinks); - } +import { Tracker } from 'jh_else_ce/tracking/tracker'; +import { addExperimentContext } from './utils'; +const Tracking = Object.assign(Tracker, { /** * Returns an implementation of this class in the form of * a Vue mixin. @@ -270,7 +9,7 @@ export default class Tracking { * @param {Object} opts - default options for all events * @returns {Object} */ - static mixin(opts = {}) { + mixin(opts = {}) { return { computed: { trackingCategory() { @@ -294,5 +33,7 @@ export default class Tracking { }, }, }; - } -} + }, +}); + +export default Tracking; diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index 4544373d8aa..438ae2bc1bc 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -32,6 +32,7 @@ const populateUserInfo = (user) => { ([userData, status]) => { if (userData) { Object.assign(user, { + id: userId, avatarUrl: userData.avatar_url, bot: userData.bot, username: userData.username, @@ -42,6 +43,7 @@ const populateUserInfo = (user) => { websiteUrl: userData.website_url, pronouns: userData.pronouns, localTime: userData.local_time, + isFollowed: userData.is_followed, loaded: true, }); } @@ -97,15 +99,29 @@ export default function addPopovers(elements = document.querySelectorAll('.js-us bio: null, workInformation: null, status: null, + isFollowed: false, loaded: false, }; const renderedPopover = new UserPopoverComponent({ propsData: { target: el, user, + placement: el.dataset.placement || 'top', }, }); + const { userId } = el.dataset; + + renderedPopover.$on('follow', () => { + UsersCache.updateById(userId, { is_followed: true }); + user.isFollowed = true; + }); + + renderedPopover.$on('unfollow', () => { + UsersCache.updateById(userId, { is_followed: false }); + user.isFollowed = false; + }); + initializedPopovers.set(el, renderedPopover); renderedPopover.$mount(); diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index f7a5589af90..e1e5cc565c6 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -35,7 +35,7 @@ function UsersSelect(currentUser, els, options = {}) { } } - const { handleClick } = options; + const { handleClick, autoAssignToMe } = options; const userSelect = this; $els.each((i, dropdown) => { @@ -172,10 +172,7 @@ function UsersSelect(currentUser, els, options = {}) { }); }; - $assignToMeLink.on('click', (e) => { - e.preventDefault(); - $(e.currentTarget).hide(); - + const onAssignToMeClick = () => { if ($dropdown.data('multiSelect')) { assignYourself(); checkMaxSelect(); @@ -194,8 +191,19 @@ function UsersSelect(currentUser, els, options = {}) { .text(gon.current_user_fullname) .removeClass('is-default'); } + }; + + $assignToMeLink.on('click', (e) => { + e.preventDefault(); + $(e.currentTarget).hide(); + onAssignToMeClick(); }); + if (autoAssignToMe) { + $assignToMeLink.hide(); + onAssignToMeClick(); + } + $block.on('click', '.js-assign-yourself', (e) => { e.preventDefault(); return assignTo(userSelect.currentUser.id); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue index 492e68b636f..437d035fbf5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue @@ -12,6 +12,11 @@ export default { }, mixins: [glFeatureFlagMixin()], props: { + state: { + type: String, + required: false, + default: '', + }, isSquashEnabled: { type: Boolean, required: false, @@ -30,8 +35,16 @@ export default { type: String, required: true, }, + mergeCommitSha: { + type: String, + required: false, + default: '', + }, }, computed: { + isMerged() { + return this.state === 'merged'; + }, targetBranchEscaped() { return escape(this.targetBranch); }, @@ -39,6 +52,22 @@ export default { return n__('%d commit', '%d commits', this.isSquashEnabled ? 1 : this.commitsCount); }, message() { + if (this.glFeatures.restructuredMrWidget) { + if (this.state === 'closed') { + return s__('mrWidgetCommitsAdded|The changes were not merged into %{targetBranch}.'); + } else if (this.isMerged) { + return s__( + 'mrWidgetCommitsAdded|Changes merged into %{targetBranch} with %{mergeCommitSha}%{squashedCommits}.', + ); + } + + return this.isFastForwardEnabled + ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.') + : s__( + 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}%{squashedCommits}.', + ); + } + return this.isFastForwardEnabled ? s__('mrWidgetCommitsAdded|Adds %{commitCount} to %{targetBranch}.') : s__( @@ -48,6 +77,13 @@ export default { textDecorativeComponent() { return this.glFeatures.restructuredMrWidget ? 'span' : 'strong'; }, + squashCommitMessage() { + if (this.isMerged) { + return s__('mergedCommitsAdded|(commits were squashed)'); + } + + return n__('(squashes %d commit)', '(squashes %d commits)', this.commitsCount); + }, }, mergeCommitCount, }; @@ -69,9 +105,14 @@ export default { </template> <template #squashedCommits> <template v-if="glFeatures.restructuredMrWidget && isSquashEnabled"> - {{ n__('(squashes %d commit)', '(squashes %d commits)', commitsCount) }}</template + {{ squashCommitMessage }}</template ></template > + <template #mergeCommitSha> + <template v-if="glFeatures.restructuredMrWidget" + ><span class="label-branch">{{ mergeCommitSha }}</span></template + > + </template> </gl-sprintf> </span> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue index 24cefd63ce3..e7d5e4086bc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -2,8 +2,11 @@ import { GlButton } from '@gitlab/ui'; import createFlash from '~/flash'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; -import { s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { s__, __ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; +import showToast from '~/vue_shared/plugins/global_toast'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; import eventHub from '../../event_hub'; import approvalsMixin from '../../mixins/approvals'; import MrWidgetContainer from '../mr_widget_container.vue'; @@ -21,7 +24,7 @@ export default { ApprovalsSummaryOptional, GlButton, }, - mixins: [approvalsMixin], + mixins: [approvalsMixin, glFeatureFlagsMixin()], props: { mr: { type: Object, @@ -171,6 +174,14 @@ export default { return serviceFn() .then((data) => { this.mr.setApprovals(data); + + if ( + this.glFeatures.mrAttentionRequests && + SidebarMediator.singleton?.store.currentUserHasAttention + ) { + showToast(__('Approved. Your attention request was removed.')); + } + eventHub.$emit('MRWidgetUpdateRequested'); eventHub.$emit('ApprovalUpdated'); sidebarEventHub.$emit('removeCurrentUserAttentionRequested'); @@ -217,7 +228,7 @@ export default { <slot :is-approving="isApproving" :approve-with-auth="approveWithAuth" - :hasApproval-auth-error="hasApprovalAuthError" + :has-approval-auth-error="hasApprovalAuthError" ></slot> </template> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index f1b89c42fb5..0bc17de638b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -47,6 +47,8 @@ export default { fullData: [], isCollapsed: true, showFade: false, + modalData: undefined, + modalName: undefined, }; }, computed: { @@ -116,6 +118,9 @@ export default { return summary; }, + modalId() { + return this.modalName || `modal${this.$options.name}`; + }, }, watch: { isCollapsed(newVal) { @@ -249,7 +254,7 @@ export default { class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center" data-testid="widget-extension-top-level" > - <div class="gl-flex-grow-1"> + <div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary"> <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> <template v-else-if="hasFetchError">{{ widgetErrorText }}</template> <div v-else> @@ -306,12 +311,20 @@ export default { data-testid="extension-list-item" > <gl-intersection-observer - :options="{ rootMargin: '100px', thresholds: 0.1 }" + :options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + rootMargin: '100px', + thresholds: 0.1, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" class="gl-w-full" @appear="appear(index)" @disappear="disappear(index)" > - <child-content :data="item" :widget-label="widgetLabel" :level="2" /> + <child-content + :data="item" + :widget-label="widgetLabel" + :modal-id="modalId" + :level="2" + /> </gl-intersection-observer> </div> </dynamic-scroller-item> @@ -322,5 +335,8 @@ export default { class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7 gl-pointer-events-none" ></div> </div> + <div v-if="$options.modalComponent && modalData"> + <component :is="$options.modalComponent" :modal-id="modalId" v-bind="modalData" /> + </div> </section> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue index 5cfee21dd5e..0ca4c92a5ae 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue @@ -1,5 +1,5 @@ <script> -import { GlBadge, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlBadge, GlLink, GlSafeHtmlDirective, GlModalDirective } from '@gitlab/ui'; import StatusIcon from './status_icon.vue'; import Actions from './actions.vue'; import { generateText } from './utils'; @@ -14,6 +14,7 @@ export default { }, directives: { SafeHtml: GlSafeHtmlDirective, + GlModal: GlModalDirective, }, props: { data: { @@ -24,6 +25,11 @@ export default { type: String, required: true, }, + modalId: { + type: String, + required: false, + default: null, + }, level: { type: Number, required: true, @@ -63,6 +69,11 @@ export default { <div v-if="data.link"> <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> </div> + <div v-if="data.modal"> + <gl-link v-gl-modal="modalId" @click="data.modal.onClick"> + {{ data.modal.text }} + </gl-link> + </div> <div v-if="data.supportingText"> <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p> </div> @@ -87,6 +98,7 @@ export default { :key="childData.id" :data="childData" :widget-label="widgetLabel" + :modal-id="modalId" :level="3" data-testid="child-content" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js index 8438f3492b2..65273678fb9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js @@ -14,6 +14,7 @@ export const registerExtension = (extension) => { i18n: extension.i18n, expandEvent: extension.expandEvent, enablePolling: extension.enablePolling, + modalComponent: extension.modalComponent, computed: { ...Object.keys(extension.computed).reduce( (acc, computedKey) => ({ diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue index 01d8de132e7..456a1f17aae 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue @@ -49,7 +49,7 @@ export default { ]" class="gl-rounded-full gl-mr-3 gl-relative gl-p-2" > - <gl-loading-icon v-if="isLoading" size="md" inline class="gl-display-block" /> + <gl-loading-icon v-if="isLoading" size="lg" inline class="gl-display-block" /> <gl-icon v-else :name="$options.EXTENSION_ICON_NAMES[iconName]" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js index 5fba070f79c..cba12507eba 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js @@ -35,9 +35,7 @@ const textStyleTags = { [getStartTag('small')]: '<span class="gl-font-sm gl-text-gray-700">', }; -export const generateText = (text) => { - if (typeof text !== 'string') return null; - +const createText = (text) => { return text .replace( new RegExp( @@ -60,3 +58,21 @@ export const generateText = (text) => { ) .replace(/%{([a-z]|_)+}/g, ''); // Filter out any tags we don't know about }; + +export const generateText = (text) => { + if (typeof text === 'string') { + return createText(text); + } else if ( + typeof text === 'object' && + typeof text.text === 'string' && + typeof text.href === 'string' + ) { + return createText( + `${ + text.prependText ? `${text.prependText} ` : '' + }<a class="gl-text-decoration-underline" href="${text.href}">${text.text}</a>`, + ); + } + + return null; +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 8cdaa3316ee..e1d88099580 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -1,9 +1,5 @@ <script> import { - GlButton, - GlDropdown, - GlDropdownSectionHeader, - GlDropdownItem, GlLink, GlTooltipDirective, GlModalDirective, @@ -14,8 +10,6 @@ import { constructWebIDEPath } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; -import MrWidgetHowToMergeModal from './mr_widget_how_to_merge_modal.vue'; import MrWidgetIcon from './mr_widget_icon.vue'; export default { @@ -24,14 +18,8 @@ export default { clipboardButton, TooltipOnTruncate, MrWidgetIcon, - MrWidgetHowToMergeModal, - GlButton, - GlDropdown, - GlDropdownSectionHeader, - GlDropdownItem, GlLink, GlSprintf, - WebIdeLink, }, directives: { GlTooltip: GlTooltipDirective, @@ -107,71 +95,6 @@ export default { </gl-sprintf> </div> </div> - - <div class="branch-actions d-flex"> - <template v-if="mr.isOpen"> - <web-ide-link - v-if="!mr.sourceBranchRemoved" - :show-edit-button="false" - :show-web-ide-button="true" - :web-ide-url="webIdePath" - :web-ide-text="$options.i18n.webIdeText" - :show-gitpod-button="mr.showGitpodButton" - :gitpod-url="mr.gitpodUrl" - :gitpod-enabled="mr.gitpodEnabled" - :user-preferences-gitpod-path="mr.userPreferencesGitpodPath" - :user-profile-enable-gitpod-path="mr.userProfileEnableGitpodPath" - :gitpod-text="$options.i18n.gitpodText" - class="gl-display-none gl-md-display-inline-block gl-mr-3" - data-placement="bottom" - tabindex="0" - data-qa-selector="open_in_web_ide_button" - /> - <gl-button - v-gl-modal-directive="'modal-merge-info'" - :disabled="mr.sourceBranchRemoved" - class="js-check-out-branch gl-mr-3" - > - {{ s__('mrWidget|Check out branch') }} - </gl-button> - <mr-widget-how-to-merge-modal - :is-fork="isFork" - :can-merge="mr.canMerge" - :source-branch="mr.sourceBranch" - :source-project="mr.sourceProject" - :source-project-path="mr.sourceProjectFullPath" - :target-branch="mr.targetBranch" - :source-project-default-url="mr.sourceProjectDefaultUrl" - :reviewing-docs-path="mr.reviewingDocsPath" - /> - </template> - <gl-dropdown - v-gl-tooltip - :title="__('Download as')" - :aria-label="__('Download as')" - icon="download" - right - data-qa-selector="download_dropdown" - > - <gl-dropdown-section-header>{{ __('Download as') }}</gl-dropdown-section-header> - <gl-dropdown-item - :href="mr.emailPatchesPath" - class="js-download-email-patches" - download - data-qa-selector="download_email_patches_menu_item" - > - {{ s__('mrWidget|Email patches') }} - </gl-dropdown-item> - <gl-dropdown-item - :href="mr.plainDiffPath" - class="js-download-plain-diff" - download - data-qa-selector="download_plain_diff_menu_item" - > - {{ s__('mrWidget|Plain diff') }} - </gl-dropdown-item> - </gl-dropdown> - </div> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue index e906b8c3b59..c2a3ae361ca 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue @@ -112,11 +112,19 @@ export default { return escapeShellString(this.sourceBranch); }, }, + mounted() { + document.addEventListener('click', (e) => { + if (e.target.closest('.js-check-out-modal-trigger')) { + this.$refs.modal.show(); + } + }); + }, }; </script> <template> <gl-modal + ref="modal" modal-id="modal-merge-info" :no-enforce-focus="true" :title="$options.i18n.title" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index c0b80eef082..3b3b46e9772 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -276,12 +276,11 @@ export default { </div> </div> <div> - <span class="mr-widget-pipeline-graph"> - <span class="stage-cell"> + <span class="gl-align-items-center gl-display-inline-flex mr-widget-pipeline-graph"> + <span class="gl-align-items-center gl-display-inline-flex gl-flex-wrap stage-cell"> <linked-pipelines-mini-list v-if="triggeredBy.length" :triggered-by="triggeredBy" /> <pipeline-mini-graph v-if="hasStages" - class="gl-display-inline-block" stages-class="mr-widget-pipeline-stages" :stages="pipeline.details.stages" :is-merge-train="isMergeTrain" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue index 2cef37d5c2e..b8a1f89d232 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlLink } from '@gitlab/ui'; +import { GlSafeHtmlDirective as SafeHtml, GlLink, GlSprintf } from '@gitlab/ui'; import { s__, n__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -10,6 +10,7 @@ export default { }, components: { GlLink, + GlSprintf, }, mixins: [glFeatureFlagMixin()], props: { @@ -28,6 +29,16 @@ export default { required: false, default: true, }, + divergedCommitsCount: { + type: Number, + required: false, + default: 0, + }, + targetBranchPath: { + type: String, + required: false, + default: '', + }, }, computed: { closesText() { @@ -81,5 +92,19 @@ export default { }}</gl-link> </span> </p> + <div + v-if=" + divergedCommitsCount > 0 && glFeatures.updatedMrHeader && !glFeatures.restructuredMrWidget + " + class="diverged-commits-count" + > + <gl-sprintf :message="s__('mrWidget|The source branch is %{link} the target branch')"> + <template #link> + <gl-link :href="targetBranchPath">{{ + n__('%d commit behind', '%d commits behind', divergedCommitsCount) + }}</gl-link> + </template> + </gl-sprintf> + </div> </section> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 9499603163b..7ff1eb6e73a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -46,7 +46,7 @@ export default { <gl-button v-if="!glFeatures.restructuredMrWidget && showDisabledButton" category="primary" - variant="success" + variant="confirm" data-testid="disabled-merge-button" :disabled="true" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index a44caf886a4..aabbeac564a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -175,7 +175,7 @@ export default { {{ cancelButtonText }} </gl-button> </h4> - <section class="mr-info-list"> + <section v-if="!glFeatures.restructuredMrWidget" class="mr-info-list"> <p v-if="shouldRemoveSourceBranch"> {{ s__('mrWidget|Deletes the source branch') }} </p> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue index 6d5ca58aa20..d50e52f5ac1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue @@ -1,4 +1,5 @@ <script> +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MrWidgetAuthorTime from '../mr_widget_author_time.vue'; import statusIcon from '../mr_widget_status_icon.vue'; @@ -8,6 +9,7 @@ export default { MrWidgetAuthorTime, statusIcon, }, + mixins: [glFeatureFlagMixin()], props: { /* TODO: This is providing all store and service down when it only needs metrics and targetBranch */ @@ -29,7 +31,7 @@ export default { :date-readable="mr.metrics.readableClosedAt" /> - <section class="mr-info-list"> + <section v-if="!glFeatures.restructuredMrWidget" class="mr-info-list"> <p> {{ s__('mrWidget|The changes were not merged into') }} <a :href="mr.targetBranchPath" class="label-branch"> {{ mr.targetBranch }} </a> 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 7435f578852..def30dacf8a 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 @@ -1,5 +1,5 @@ <script> -import { GlButton, GlModalDirective, GlSkeletonLoader } from '@gitlab/ui'; +import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import userPermissionsQuery from '../../queries/permissions.query.graphql'; @@ -13,9 +13,6 @@ export default { StatusIcon, GlButton, }, - directives: { - GlModalDirective, - }, mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], apollo: { userPermissions: { @@ -131,9 +128,9 @@ export default { </gl-button> <gl-button v-if="canMerge" - v-gl-modal-directive="'modal-merge-info'" :size="glFeatures.restructuredMrWidget ? 'small' : 'medium'" data-testid="merge-locally-button" + class="js-check-out-modal-trigger" > {{ s__('mrWidget|Resolve locally') }} </gl-button> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 84dac95ce74..bf036f562ed 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -1,6 +1,7 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ import { GlLoadingIcon, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import api from '~/api'; import createFlash from '~/flash'; import { s__, __ } from '~/locale'; @@ -22,6 +23,7 @@ export default { GlLoadingIcon, GlButton, }, + mixins: [glFeatureFlagMixin()], props: { mr: { type: Object, @@ -181,7 +183,11 @@ export default { {{ s__('mrWidget|Delete source branch') }} </gl-button> </div> - <section class="mr-info-list" data-qa-selector="merged_status_content"> + <section + v-if="!glFeatures.restructuredMrWidget" + class="mr-info-list" + data-qa-selector="merged_status_content" + > <p> {{ s__('mrWidget|The changes were merged into') }} <span class="label-branch"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue index 887d1aab524..b86ab69af3f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue @@ -1,5 +1,6 @@ <script> import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import simplePoll from '~/lib/utils/simple_poll'; import MergeRequest from '~/merge_request'; import eventHub from '../../event_hub'; @@ -14,6 +15,7 @@ export default { components: { statusIcon, }, + mixins: [glFeatureFlagMixin()], props: { mr: { type: Object, @@ -88,7 +90,7 @@ export default { {{ mergeStatus.message }} <gl-emoji :data-name="mergeStatus.emoji" /> </h4> - <section class="mr-info-list"> + <section v-if="!glFeatures.restructuredMrWidget" class="mr-info-list"> <p> {{ s__('mrWidget|Merges changes into') }} <span class="label-branch"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 4f8faeb877f..4fb95fe635c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -322,11 +322,33 @@ export default { }, restructuredWidgetShowMergeButtons() { if (this.glFeatures.restructuredMrWidget) { - return this.isMergeAllowed && this.state.userPermissions.canMerge; + return ( + (this.isMergeAllowed || this.isAutoMergeAvailable) && + this.state.userPermissions.canMerge && + !this.mr.mergeOngoing && + !this.mr.autoMergeEnabled + ); } return true; }, + sourceBranchDeletedText() { + if (this.glFeatures.restructuredMrWidget) { + if (this.removeSourceBranch) { + return this.mr.state === 'merged' + ? __('Deleted the source branch.') + : __('Source branch will be deleted.'); + } + + return this.mr.state === 'merged' + ? __('Did not delete the source branch.') + : __('Source branch will not be deleted.'); + } + + return this.removeSourceBranch + ? __('Deletes the source branch.') + : __('Does not delete the source branch.'); + }, }, mounted() { if (this.glFeatures.mergeRequestWidgetGraphql) { @@ -421,6 +443,8 @@ export default { if (this.glFeatures.mergeRequestWidgetGraphql) { this.updateGraphqlState(); } + + this.isMakingRequest = false; }) .catch(() => { this.isMakingRequest = false; @@ -499,6 +523,7 @@ export default { <template> <div + data-testid="ready_to_merge_state" :class="{ 'gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7 gl-rounded-bottom-left-base gl-rounded-bottom-right-base': glFeatures.restructuredMrWidget, @@ -611,6 +636,7 @@ export default { glFeatures.restructuredMrWidget && (shouldShowSquashEdit || shouldShowMergeEdit) " v-model="editCommitMessage" + data-testid="widget_edit_commit_message" class="gl-display-flex gl-align-items-center" > {{ __('Edit commit message') }} @@ -686,25 +712,36 @@ export default { v-if="!restructuredWidgetShowMergeButtons" class="gl-w-full gl-order-n1 gl-text-gray-500" > - <strong> + <strong v-if="mr.state !== 'closed'"> {{ __('Merge details') }} </strong> <ul class="gl-pl-4 gl-m-0"> + <li + v-if="mr.divergedCommitsCount > 0 && glFeatures.updatedMrHeader" + class="gl-line-height-normal" + > + <gl-sprintf + :message="s__('mrWidget|The source branch is %{link} the target branch')" + > + <template #link> + <gl-link :href="mr.targetBranchPath">{{ + n__('%d commit behind', '%d commits behind', mr.divergedCommitsCount) + }}</gl-link> + </template> + </gl-sprintf> + </li> <li class="gl-line-height-normal"> <added-commit-message + :state="mr.state" + :merge-commit-sha="mr.shortMergeCommitSha" :is-squash-enabled="squashBeforeMerge" :is-fast-forward-enabled="!shouldShowMergeEdit" :commits-count="commitsCount" :target-branch="stateData.targetBranch" /> </li> - <li class="gl-line-height-normal"> - <template v-if="removeSourceBranch"> - {{ __('Deletes the source branch.') }} - </template> - <template v-else> - {{ __('Does not delete the source branch.') }} - </template> + <li v-if="mr.state !== 'closed'" class="gl-line-height-normal"> + {{ sourceBranchDeletedText }} </li> <li v-if="mr.relatedLinks" class="gl-line-height-normal"> <related-links @@ -733,6 +770,8 @@ export default { :state="mr.state" :related-links="mr.relatedLinks" :show-assign-to-me="false" + :diverged-commits-count="mr.divergedCommitsCount" + :target-branch-path="mr.targetBranchPath" class="mr-ready-merge-related-links gl-display-inline" /> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js index d32db50874c..cea8df2484b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js @@ -83,14 +83,11 @@ export default { this.collapsedData.newErrors.map((e) => { return fullData.push({ text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`, - subtext: sprintf( - s__(`ciReport|in %{open_link}${e.file_path}:${e.line}%{close_link}`), - { - open_link: `<a class="gl-text-decoration-underline" href="${e.urlPath}">`, - close_link: '</a>', - }, - false, - ), + subtext: { + prependText: s__(`ciReport|in`), + text: `${e.file_path}:${e.line}`, + href: e.urlPath, + }, icon: { name: SEVERITY_ICONS_EXTENSION[e.severity], }, diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js index cd5cfb6837c..23f14bea4e1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js @@ -10,6 +10,8 @@ export const i18n = { label: s__('Reports|Test summary'), loading: s__('Reports|Test summary results are loading'), error: s__('Reports|Test summary failed to load results'), + newHeader: s__('Reports|New'), + fixedHeader: s__('Reports|Fixed'), fullReport: s__('Reports|Full report'), noChanges: (bold) => s__(`Reports|${noText(bold)} changed test results`), @@ -36,4 +38,32 @@ export const i18n = { sprintf(s__('Reports|An error occurred while loading %{name} results'), { name }), headReportParsingError: s__('Reports|Head report parsing error:'), baseReportParsingError: s__('Reports|Base report parsing error:'), + + recentFailureSummary: (recentlyFailed, failed) => { + if (failed < 2) { + return sprintf( + s__( + 'Reports|%{recentlyFailed} out of %{failed} failed test has failed more than once in the last 14 days', + ), + { recentlyFailed, failed }, + ); + } + return sprintf( + n__( + 'Reports|%{recentlyFailed} out of %{failed} failed tests has failed more than once in the last 14 days', + 'Reports|%{recentlyFailed} out of %{failed} failed tests have failed more than once in the last 14 days', + recentlyFailed, + ), + { recentlyFailed, failed }, + ); + }, + recentFailureCount: (recentFailures) => + sprintf( + n__( + 'Reports|Failed %{count} time in %{base_branch} in the last 14 days', + 'Reports|Failed %{count} times in %{base_branch} in the last 14 days', + recentFailures.count, + ), + recentFailures, + ), }; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js index 65d9257903f..577b2cbfc5c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js @@ -1,7 +1,13 @@ import { uniqueId } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import { EXTENSION_ICONS } from '../../constants'; -import { summaryTextBuilder, reportTextBuilder, reportSubTextBuilder } from './utils'; +import { + summaryTextBuilder, + reportTextBuilder, + reportSubTextBuilder, + countRecentlyFailedTests, + recentFailuresTextBuilder, +} from './utils'; import { i18n, TESTS_FAILED_STATUS, ERROR_STATUS } from './constants'; export default { @@ -18,7 +24,10 @@ export default { if (data.hasSuiteError) { return this.$options.i18n.error; } - return summaryTextBuilder(this.$options.i18n.label, data.summary); + return { + subject: summaryTextBuilder(this.$options.i18n.label, data.summary), + meta: recentFailuresTextBuilder(data.summary), + }; }, statusIcon(data) { if (data.parsingInProgress) { @@ -50,6 +59,10 @@ export default { hasSuiteError: data.suites?.some((suite) => suite.status === ERROR_STATUS), parsingInProgress: status === 204, ...data, + summary: { + recentlyFailed: countRecentlyFailedTests(data.suites), + ...data.summary, + }, }, }; }); @@ -66,17 +79,66 @@ export default { } return EXTENSION_ICONS.success; }, - prepareReports() { - return this.collapsedData.suites.map((suite) => { + testHeader(test, sectionHeader, index) { + const headers = []; + if (index === 0) { + headers.push(sectionHeader); + } + if (test.recent_failures?.count && test.recent_failures?.base_branch) { + headers.push(i18n.recentFailureCount(test.recent_failures)); + } + return headers; + }, + mapTestAsChild({ iconName, sectionHeader }) { + return (test, index) => { return { - id: uniqueId('suite-'), - text: reportTextBuilder(suite), - subtext: reportSubTextBuilder(suite), - icon: { - name: this.suiteIcon(suite), - }, + id: uniqueId('test-'), + header: this.testHeader(test, sectionHeader, index), + icon: { name: iconName }, + text: test.name, }; - }); + }; + }, + prepareReports() { + return this.collapsedData.suites + .map((suite) => { + return { + ...suite, + summary: { + recentlyFailed: countRecentlyFailedTests(suite), + ...suite.summary, + }, + }; + }) + .map((suite) => { + return { + id: uniqueId('suite-'), + text: reportTextBuilder(suite), + subtext: reportSubTextBuilder(suite), + icon: { + name: this.suiteIcon(suite), + }, + children: [ + ...[...suite.new_failures, ...suite.new_errors].map( + this.mapTestAsChild({ + sectionHeader: i18n.newHeader, + iconName: EXTENSION_ICONS.failed, + }), + ), + ...[...suite.existing_failures, ...suite.existing_errors].map( + this.mapTestAsChild({ + iconName: EXTENSION_ICONS.failed, + }), + ), + ...[...suite.resolved_failures, ...suite.resolved_errors].map( + this.mapTestAsChild({ + sectionHeader: i18n.fixedHeader, + iconName: EXTENSION_ICONS.success, + }), + ), + ], + }; + }); }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js index a74ed20362f..9e4b0ac581c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js @@ -43,13 +43,42 @@ export const reportTextBuilder = ({ name = '', summary = {}, status }) => { return i18n.summaryText(name, resultsString); }; -export const reportSubTextBuilder = ({ suite_errors }) => { - const errors = []; - if (suite_errors?.head) { - errors.push(`${i18n.headReportParsingError} ${suite_errors.head}`); - } - if (suite_errors?.base) { - errors.push(`${i18n.baseReportParsingError} ${suite_errors.base}`); +export const recentFailuresTextBuilder = (summary = {}) => { + const { failed, recentlyFailed } = summary; + if (!failed || !recentlyFailed) return ''; + + return i18n.recentFailureSummary(recentlyFailed, failed); +}; + +export const reportSubTextBuilder = ({ suite_errors, summary }) => { + if (suite_errors?.head || suite_errors?.base) { + const errors = []; + if (suite_errors?.head) { + errors.push(`${i18n.headReportParsingError} ${suite_errors.head}`); + } + if (suite_errors?.base) { + errors.push(`${i18n.baseReportParsingError} ${suite_errors.base}`); + } + return errors.join('<br />'); } - return errors.join('<br />'); + return recentFailuresTextBuilder(summary); +}; + +export const countRecentlyFailedTests = (subject) => { + // handle either a single report or an array of reports + const reports = !subject.length ? [subject] : subject; + + return reports + .map((report) => { + return ( + [report.new_failures, report.existing_failures, report.resolved_failures] + // only count tests which have failed more than once + .map( + (failureArray) => + failureArray.filter((failure) => failure.recent_failures?.count > 1).length, + ) + .reduce((total, count) => total + count, 0) + ); + }) + .reduce((total, count) => total + count, 0); }; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 4b3ad288768..8ebb7f6f159 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -165,7 +165,10 @@ export default { return this.mr?.codequalityReportsPath; }, shouldRenderRelatedLinks() { - return Boolean(this.mr.relatedLinks) && !this.mr.isNothingToMergeState; + return ( + (Boolean(this.mr.relatedLinks) || this.mr.divergedCommitsCount > 0) && + !this.mr.isNothingToMergeState + ); }, shouldRenderSourceBranchRemovalStatus() { return ( @@ -195,6 +198,9 @@ export default { shouldRenderTestReport() { return Boolean(this.mr?.testResultsPath); }, + shouldRenderRefactoredTestReport() { + return window.gon?.features?.refactorMrWidgetTestSummary; + }, mergeError() { let { mergeError } = this.mr; @@ -228,6 +234,9 @@ export default { isRestructuredMrWidgetEnabled() { return window.gon?.features?.restructuredMrWidget; }, + isUpdatedHeaderEnabled() { + return window.gon?.features?.updatedMrHeader; + }, }, watch: { 'mr.machineValue': { @@ -512,7 +521,7 @@ export default { } }, registerTestReportExtension() { - if (this.shouldRenderTestReport && this.shouldShowExtension) { + if (this.shouldRenderTestReport && this.shouldRenderRefactoredTestReport) { registerExtension(testReportExtension); } }, @@ -521,11 +530,15 @@ export default { </script> <template> <div v-if="isLoaded" class="mr-state-widget gl-mt-3"> - <header class="gl-rounded-base gl-border-solid gl-border-1 gl-border-gray-100"> + <header + v-if="shouldRenderCollaborationStatus || !isUpdatedHeaderEnabled" + :class="{ 'mr-widget-workflow gl-mt-0!': isUpdatedHeaderEnabled }" + class="gl-rounded-base gl-border-solid gl-border-1 gl-border-gray-100 gl-overflow-hidden" + > <mr-widget-alert-message v-if="shouldRenderCollaborationStatus" type="info"> {{ s__('mrWidget|Members who can merge are allowed to add commits.') }} </mr-widget-alert-message> - <mr-widget-header :mr="mr" /> + <mr-widget-header v-if="!isUpdatedHeaderEnabled" :mr="mr" /> </header> <mr-widget-suggest-pipeline v-if="shouldSuggestPipelines" @@ -588,7 +601,7 @@ export default { /> <grouped-test-reports-app - v-if="mr.testResultsPath && !shouldShowExtension" + v-if="shouldRenderTestReport && !shouldRenderRefactoredTestReport" class="js-reports-container" :endpoint="mr.testResultsPath" :head-blob-path="mr.headBlobPath" @@ -617,6 +630,8 @@ export default { v-if="shouldRenderRelatedLinks" :state="mr.state" :related-links="mr.relatedLinks" + :diverged-commits-count="mr.divergedCommitsCount" + :target-branch-path="mr.targetBranchPath" class="mr-info-list gl-ml-7 gl-pb-5" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql index 99e6f4e9beb..efc0673bc26 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql @@ -11,7 +11,6 @@ fragment ReadyToMerge on Project { shouldRemoveSourceBranch forceRemoveSourceBranch defaultMergeCommitMessage - defaultMergeCommitMessageWithDescription defaultSquashCommitMessage squash squashOnMerge diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index eb07609d5d6..146cf7e11a7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -1,5 +1,5 @@ import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; -import { statusBoxState } from '~/issuable/components/status_box.vue'; +import { badgeState } from '~/issuable/components/status_box.vue'; import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; import { machine } from '~/lib/utils/finite_state_machine'; import { @@ -221,8 +221,8 @@ export default class MergeRequestStore { } updateStatusState(state) { - if (this.mergeRequestState !== state && statusBoxState.updateStatus) { - statusBoxState.updateStatus(); + if (this.mergeRequestState !== state && badgeState.updateStatus) { + badgeState.updateStatus(); } } 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 948d2505966..c93f620995f 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 @@ -278,7 +278,7 @@ export default { data-testid="viewIncidentBtn" :href="incidentPath(alert.issue.iid)" category="primary" - variant="success" + variant="confirm" > {{ s__('AlertManagement|View incident') }} </gl-button> @@ -288,7 +288,7 @@ export default { data-testid="createIncidentBtn" :loading="incidentCreationInProgress" category="primary" - variant="success" + variant="confirm" @click="createIncident()" > {{ s__('AlertManagement|Create incident') }} diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue index 8b76af05ffe..6a03e38a31d 100644 --- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue @@ -1,12 +1,12 @@ <script> -import { GlSegmentedControl } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; +import SegmentedControlButtonGroup from '~/vue_shared/components/segmented_control_button_group.vue'; import CiCdAnalyticsAreaChart from './ci_cd_analytics_area_chart.vue'; export default { components: { - GlSegmentedControl, CiCdAnalyticsAreaChart, + SegmentedControlButtonGroup, }, props: { charts: { @@ -38,7 +38,11 @@ export default { </script> <template> <div> - <gl-segmented-control v-model="selectedChart" :options="chartRanges" class="gl-mb-4" /> + <segmented-control-button-group + v-model="selectedChart" + :options="chartRanges" + class="gl-mb-4" + /> <ci-cd-analytics-area-chart v-if="chart" v-bind="$attrs" diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index 07bd6019b80..9bccc49e894 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -11,17 +11,22 @@ import { GlIcon } from '@gitlab/ui'; * } * * Used in: - * - Pipelines table Badge - * - Pipelines table mini graph - * - Pipeline graph - * - Pipeline show view badge - * - Jobs table + * - Extended MR Popover * - Jobs show view header * - Jobs show view sidebar + * - Jobs table * - Linked pipelines - * - Extended MR Popover + * - Pipeline graph + * - Pipeline mini graph + * - Pipeline show view badge + * - Pipelines table Badge + */ + +/* + * These sizes are defined in gitlab-ui/src/scss/variables.scss + * under '$gl-icon-sizes' */ -const validSizes = [8, 12, 16, 18, 24, 32, 48, 72]; +const validSizes = [8, 12, 14, 16, 24, 32, 48, 72]; export default { components: { @@ -45,6 +50,11 @@ export default { required: false, default: false, }, + isInteractive: { + type: Boolean, + required: false, + default: false, + }, cssClasses: { type: String, required: false, @@ -52,9 +62,9 @@ export default { }, }, computed: { - cssClass() { + wrapperStyleClasses() { const status = this.status.group; - return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`; + return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status} gl-rounded-full gl-justify-content-center`; }, icon() { return this.borderless ? `${this.status.icon}_borderless` : this.status.icon; @@ -63,7 +73,10 @@ export default { }; </script> <template> - <span :class="cssClass"> + <span + :class="[wrapperStyleClasses, { interactive: isInteractive }]" + :style="{ height: `${size}px`, width: `${size}px` }" + > <gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue index 7a166f9a3e4..78db2bf15b0 100644 --- a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue +++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue @@ -4,6 +4,7 @@ * * @example * <color-picker + :id="example-id" :invalid-feedback="__('Please enter a valid hex (#RRGGBB or #RGB) color value')" :label="__('Background color')" :value="#FF0000" @@ -12,6 +13,7 @@ /> */ import { GlFormGroup, GlFormInput, GlFormInputGroup, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; import { __ } from '~/locale'; const PREVIEW_COLOR_DEFAULT_CLASSES = @@ -29,6 +31,11 @@ export default { GlTooltip: GlTooltipDirective, }, props: { + id: { + type: String, + required: false, + default: () => uniqueId('color-picker-'), + }, invalidFeedback: { type: String, required: false, @@ -94,14 +101,13 @@ export default { <div> <gl-form-group :label="label" - label-for="color-picker" + :label-for="id" :description="description" :invalid-feedback="invalidFeedback" :state="state" :class="{ 'gl-mb-3!': hasSuggestedColors }" > <gl-form-input-group - id="color-picker" max-length="7" type="text" class="gl-align-center gl-rounded-0 gl-rounded-top-right-base gl-rounded-bottom-right-base" @@ -112,6 +118,7 @@ export default { <template #prepend> <div :class="previewColorClasses" :style="previewColor" data-testid="color-preview"> <gl-form-input + :id="id" type="color" class="gl-absolute gl-top-0 gl-left-0 gl-h-full! gl-p-0! gl-m-0! gl-opacity-0" tabindex="-1" diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index ebbc1bfb037..388353bc35b 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -172,7 +172,9 @@ export default { :img-src="author.avatar_url" :img-alt="userImageAltDescription" :tooltip-text="author.username" + :img-size="16" class="avatar-image-container text-decoration-none" + img-css-classes="gl-mr-3" /> <tooltip-on-truncate :title="title" class="flex-truncate-child"> <gl-link :href="commitUrl" class="commit-row-message cgray">{{ title }}</gl-link> diff --git a/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue b/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue new file mode 100644 index 00000000000..298c7bc50cc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue @@ -0,0 +1,39 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { confidentialityInfoText } from '../constants'; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + workspaceType: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: true, + }, + }, + computed: { + confidentialTooltip() { + return confidentialityInfoText(this.workspaceType, this.issuableType); + }, + }, +}; +</script> + +<template> + <gl-badge + v-gl-tooltip.bottom + :title="confidentialTooltip" + icon="eye-slash" + variant="warning" + class="gl-display-inline gl-mr-2" + >{{ __('Confidential') }}</gl-badge + > +</template> diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js index 6629b293eb9..8481280f25f 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js @@ -14,6 +14,8 @@ const Template = (args, { argTypes }) => ({ additionalInformation: args.additionalInformation || null, confirmDangerMessage: args.confirmDangerMessage || 'You require more Vespene Gas', htmlConfirmationMessage: args.confirmDangerMessage || false, + confirmButtonText: args.confirmButtonText || 'Cancel', + cancelButtonText: args.cancelButtonText || 'Confirm', }, }); diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue index 88890b3332d..37e480f7e41 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue @@ -12,7 +12,7 @@ import { CONFIRM_DANGER_MODAL_TITLE, CONFIRM_DANGER_PHRASE_TEXT, CONFIRM_DANGER_WARNING, - CONFIRM_DANGER_MODAL_ERROR, + CONFIRM_DANGER_MODAL_CANCEL, } from './constants'; export default { @@ -40,6 +40,9 @@ export default { additionalInformation: { default: CONFIRM_DANGER_WARNING, }, + cancelButtonText: { + default: CONFIRM_DANGER_MODAL_CANCEL, + }, }, props: { modalId: { @@ -66,6 +69,11 @@ export default { attributes: [{ variant: 'danger', disabled: !this.isValid, class: 'qa-confirm-button' }], }; }, + actionCancel() { + return { + text: this.cancelButtonText, + }; + }, }, methods: { equalString(a, b) { @@ -77,7 +85,6 @@ export default { CONFIRM_DANGER_MODAL_TITLE, CONFIRM_DANGER_WARNING, CONFIRM_DANGER_PHRASE_TEXT, - CONFIRM_DANGER_MODAL_ERROR, }, }; </script> @@ -88,6 +95,7 @@ export default { :data-testid="modalId" :title="$options.i18n.CONFIRM_DANGER_MODAL_TITLE" :action-primary="actionPrimary" + :action-cancel="actionCancel" @primary="$emit('confirm')" > <gl-alert @@ -110,7 +118,7 @@ export default { </template> </gl-sprintf> </p> - <gl-form-group :state="isValid" :invalid-feedback="$options.i18n.CONFIRM_DANGER_MODAL_ERROR"> + <gl-form-group :state="isValid"> <gl-form-input id="confirm_name_input" v-model="confirmationPhrase" diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js b/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js index fa44a9be411..90d55d0f93f 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js @@ -2,7 +2,6 @@ import { __ } from '~/locale'; export const CONFIRM_DANGER_MODAL_ID = 'confirm-danger-modal'; export const CONFIRM_DANGER_MODAL_TITLE = __('Confirmation required'); -export const CONFIRM_DANGER_MODAL_ERROR = __('Confirmation required'); export const CONFIRM_DANGER_MODAL_BUTTON = __('Confirm'); export const CONFIRM_DANGER_WARNING = __( 'This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.', @@ -10,3 +9,4 @@ export const CONFIRM_DANGER_WARNING = __( export const CONFIRM_DANGER_PHRASE_TEXT = __( 'Please type %{phrase_code} to proceed or close this modal to cancel.', ); +export const CONFIRM_DANGER_MODAL_CANCEL = __('Cancel'); diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index e546ca57c5e..181c1b89e31 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -248,7 +248,7 @@ export default { __('Cancel') }}</gl-button> <gl-button - variant="success" + variant="confirm" category="primary" :disabled="!isValid" @click="setFixedRange()" diff --git a/app/assets/javascripts/vue_shared/components/deployment_instance.vue b/app/assets/javascripts/vue_shared/components/deployment_instance.vue index 41b783aa011..4aae86fc82b 100644 --- a/app/assets/javascripts/vue_shared/components/deployment_instance.vue +++ b/app/assets/javascripts/vue_shared/components/deployment_instance.vue @@ -14,6 +14,7 @@ */ import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import { mergeUrlParams } from '~/lib/utils/url_utility'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -22,7 +23,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - + mixins: [glFeatureFlagsMixin()], props: { /** * Represents the status of the pod. Each state is represented with a different @@ -75,7 +76,9 @@ export default { }, computedLogPath() { - return this.isLink ? mergeUrlParams({ pod_name: this.podName }, this.logsPath) : null; + return this.isLink && this.glFeatures.monitorLogging + ? mergeUrlParams({ pod_name: this.podName }, this.logsPath) + : null; }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_hidden_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_hidden_input.vue deleted file mode 100644 index afde0c81580..00000000000 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_hidden_input.vue +++ /dev/null @@ -1,18 +0,0 @@ -<script> -export default { - props: { - name: { - type: String, - required: true, - }, - value: { - type: [Number, String], - required: true, - }, - }, -}; -</script> - -<template> - <input :name="name" :value="value" type="hidden" /> -</template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue deleted file mode 100644 index edb5ffdc39c..00000000000 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue +++ /dev/null @@ -1,49 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { - GlIcon, - }, - props: { - placeholderText: { - type: String, - required: true, - default: __('Search'), - }, - focused: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { searchQuery: this.value }; - }, - watch: { - searchQuery(query) { - this.$emit('input', query); - }, - focused(val) { - if (val) { - this.$refs.searchInput.focus(); - } - }, - }, -}; -</script> - -<template> - <div class="dropdown-input"> - <input - ref="searchInput" - v-model="searchQuery" - :placeholder="placeholderText" - class="dropdown-input-field" - type="search" - autocomplete="off" - /> - <gl-icon name="search" class="dropdown-input-search" data-hidden="true" /> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue index 2a79ccc2648..840911dc99c 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue @@ -1,28 +1,22 @@ <script> import { - GlIcon, GlLoadingIcon, GlDropdown, GlDropdownForm, GlDropdownDivider, GlDropdownItem, - GlDropdownSectionHeader, GlSearchBoxByType, } from '@gitlab/ui'; import { __ } from '~/locale'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; export default { components: { - GlIcon, GlLoadingIcon, GlDropdown, GlDropdownForm, GlDropdownDivider, GlDropdownItem, - GlDropdownSectionHeader, GlSearchBoxByType, - TooltipOnTruncate, }, props: { selectText: { @@ -45,11 +39,6 @@ export default { required: false, default: () => [], }, - groupedOptions: { - type: Array, - required: false, - default: () => [], - }, isLoading: { type: Boolean, required: false, @@ -70,6 +59,11 @@ export default { required: false, default: false, }, + customIsSelectedOption: { + type: Function, + required: false, + default: undefined, + }, }, computed: { isSearchEmpty() { @@ -87,6 +81,9 @@ export default { } }, isSelected(option) { + if (this.customIsSelectedOption !== undefined) { + return this.customIsSelectedOption(option); + } if (Array.isArray(this.selected)) { return this.selected.some((label) => label.title === option.title); } @@ -143,7 +140,7 @@ export default { <gl-dropdown-form class="gl-relative gl-min-h-7" data-qa-selector="labels_dropdown_content"> <gl-loading-icon v-if="isLoading" - size="md" + size="lg" class="gl-absolute gl-left-0 gl-top-0 gl-right-0" /> <template v-else> @@ -177,36 +174,7 @@ export default { {{ option.title }} </slot> </gl-dropdown-item> - <template v-for="(optionGroup, index) in groupedOptions"> - <gl-dropdown-divider v-if="index !== 0" :key="index" /> - <gl-dropdown-section-header :key="optionGroup.id"> - <div class="gl-display-flex gl-max-w-full"> - <tooltip-on-truncate - :title="optionGroup.title" - class="gl-text-truncate gl-flex-grow-1" - > - {{ optionGroup.title }} - </tooltip-on-truncate> - <span v-if="optionGroup.secondaryText" class="gl-float-right gl-font-weight-normal"> - <gl-icon name="clock" class="gl-mr-2" /> - {{ optionGroup.secondaryText }} - </span> - </div> - </gl-dropdown-section-header> - <gl-dropdown-item - v-for="option in optionGroup.options" - :key="optionKey(option)" - :is-checked="isSelected(option)" - is-check-centered - is-check-item - data-testid="unselected-option" - @click="selectOption(option)" - > - <slot name="item" :item="option"> - {{ option.title }} - </slot> - </gl-dropdown-item> - </template> + <slot v-bind="{ isSelected }" name="grouped-options"></slot> <gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!"> {{ $options.i18n.noMatchingResults }} </gl-dropdown-item> diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index b0c1c1531aa..680f229d5e8 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import Mousetrap from 'mousetrap'; import VirtualList from 'vue-virtual-scroll-list'; @@ -9,13 +9,13 @@ import Item from './item.vue'; export const MAX_FILE_FINDER_RESULTS = 40; export const FILE_FINDER_ROW_HEIGHT = 55; -export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33; const originalStopCallback = Mousetrap.prototype.stopCallback; export default { components: { GlIcon, + GlLoadingIcon, Item, VirtualList, }, @@ -71,7 +71,7 @@ export default { return this.filteredBlobsLength ? Math.min(this.filteredBlobsLength, 5) : 1; }, listHeight() { - return this.filteredBlobsLength ? FILE_FINDER_ROW_HEIGHT : FILE_FINDER_EMPTY_ROW_HEIGHT; + return FILE_FINDER_ROW_HEIGHT; }, showClearInputButton() { return this.searchText.trim() !== ''; @@ -265,9 +265,9 @@ export default { </li> </template> <li v-else class="dropdown-menu-empty-item"> - <div class="gl-mr-3 gl-ml-3 gl-mt-3 gl-mb-3"> + <div class="gl-mr-3 gl-ml-3 gl-mt-5 gl-mb-3"> <template v-if="loading"> - {{ __('Loading...') }} + <gl-loading-icon /> </template> <template v-else> {{ __('No files found.') }} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 3d48c74b40b..d7a84798e47 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -1,6 +1,6 @@ import { __ } from '~/locale'; -export const DEBOUNCE_DELAY = 200; +export const DEBOUNCE_DELAY = 500; export const MAX_RECENT_TOKENS_SIZE = 3; export const FILTER_NONE = 'None'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 6638a5de62f..33d507dad57 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -89,32 +89,20 @@ export default { required: false, default: () => ({}), }, + syncFilterAndSort: { + type: Boolean, + required: false, + default: false, + }, }, data() { - let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending; - let selectedSortDirection = SortDirection.descending; - - // Extract correct sortBy value based on initialSortBy - if (this.initialSortBy) { - selectedSortOption = this.sortOptions - .filter( - (sortBy) => - sortBy.sortDirection.ascending === this.initialSortBy || - sortBy.sortDirection.descending === this.initialSortBy, - ) - .pop(); - selectedSortDirection = Object.keys(selectedSortOption.sortDirection).find( - (key) => selectedSortOption.sortDirection[key] === this.initialSortBy, - ); - } - return { initialRender: true, recentSearchesPromise: null, recentSearches: [], filterValue: this.initialFilterValue, - selectedSortOption, - selectedSortDirection, + selectedSortOption: this.sortOptions[0], + selectedSortDirection: SortDirection.descending, }; }, computed: { @@ -173,7 +161,20 @@ export default { return undefined; }, }, + watch: { + initialFilterValue(newValue) { + if (this.syncFilterAndSort) { + this.filterValue = newValue; + } + }, + initialSortBy(newValue) { + if (this.syncFilterAndSort) { + this.updateSelectedSortValues(newValue); + } + }, + }, created() { + this.updateSelectedSortValues(this.initialSortBy); if (this.recentSearchesStorageKey) this.setupRecentSearch(); }, methods: { @@ -309,12 +310,25 @@ export default { const cleared = true; this.$emit('onFilter', [], cleared); }, + updateSelectedSortValues(sort) { + if (!sort) { + return; + } + + this.selectedSortOption = this.sortOptions.find( + (sortBy) => + sortBy.sortDirection.ascending === sort || sortBy.sortDirection.descending === sort, + ); + this.selectedSortDirection = Object.keys(this.selectedSortOption.sortDirection).find( + (key) => this.selectedSortOption.sortDirection[key] === sort, + ); + }, }, }; </script> <template> - <div class="vue-filtered-search-bar-container d-md-flex"> + <div class="vue-filtered-search-bar-container gl-md-display-flex"> <gl-form-checkbox v-if="showCheckbox" class="gl-align-self-center" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index 696456be990..848c49c48c7 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -87,6 +87,7 @@ export default { :get-active-token-value="getActiveAuthor" :default-suggestions="defaultAuthors" :preloaded-suggestions="preloadedAuthors" + v-bind="$attrs" @fetch-suggestions="fetchAuthors" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index e7923e0b55e..c3a0a97a7ba 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -211,10 +211,22 @@ export default { @select="handleTokenValueSelected" > <template #view-token="viewTokenProps"> - <slot name="view-token" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot> + <slot + name="view-token" + :view-token-props="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + ...viewTokenProps, + activeTokenValue, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + ></slot> </template> <template #view="viewTokenProps"> - <slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot> + <slot + name="view" + :view-token-props="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + ...viewTokenProps, + activeTokenValue, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + ></slot> </template> <template v-if="suggestionsEnabled" #suggestions> <template v-if="showDefaultSuggestions"> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue index 4ecfc1cf40c..aa5161ca93c 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue @@ -65,6 +65,7 @@ export default { :suggestions="branches" :suggestions-loading="loading" :get-active-token-value="getActiveBranch" + v-bind="$attrs" @fetch-suggestions="fetchBranches" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue index 5a69751a2cc..210d814d22a 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -67,6 +67,7 @@ export default { :suggestions="emojis" :suggestions-loading="loading" :get-active-token-value="getActiveEmoji" + v-bind="$attrs" @fetch-suggestions="fetchEmojis" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index 3f7a8920f48..6f24955814c 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -104,6 +104,7 @@ export default { :suggestions="labels" :get-active-token-value="getActiveLabel" :default-suggestions="defaultLabels" + v-bind="$attrs" @fetch-suggestions="fetchLabels" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 11c081ab4f8..69265d0fdc9 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -84,6 +84,7 @@ export default { :suggestions="milestones" :suggestions-loading="loading" :get-active-token-value="getActiveMilestone" + v-bind="$attrs" @fetch-suggestions="fetchMilestones" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue index f353cc3a765..9e68c92af5d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue @@ -66,6 +66,7 @@ export default { :suggestions="releases" :suggestions-loading="loading" :get-active-token-value="getActiveRelease" + v-bind="$attrs" @fetch-suggestions="fetchReleases" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 722df3cc58b..1f309a19b14 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -111,6 +111,16 @@ export default { required: false, default: false, }, + showCommentToolBar: { + type: Boolean, + required: false, + default: true, + }, + restrictedToolBarItems: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -331,7 +341,7 @@ export default { :enable-preview="enablePreview" :show-suggest-popover="showSuggestPopover" :suggestion-start-index="suggestionsStartIndex" - data-testid="markdownHeader" + :restricted-tool-bar-items="restrictedToolBarItems" @preview-markdown="showPreviewTab" @write-markdown="showWriteTab" @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" @@ -350,6 +360,7 @@ export default { :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" :can-attach-file="canAttachFile" + :show-comment-tool-bar="showCommentToolBar" /> </div> </div> @@ -362,8 +373,6 @@ export default { <suggestions v-if="hasSuggestion" :note-html="markdownPreview" - :from-line="lineNumber" - :from-content="lineContent" :line-type="lineType" :disabled="true" :suggestions="suggestions" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index d0bd5046bf0..ba2b5eaa4f9 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -54,6 +54,11 @@ export default { required: false, default: true, }, + restrictedToolBarItems: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -193,7 +198,10 @@ export default { <toolbar-button tag="**" :button-title=" - sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey }) + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { + modifierKey, + }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */ " :shortcuts="$options.shortcuts.bold" icon="bold" @@ -201,22 +209,28 @@ export default { <toolbar-button tag="_" :button-title=" - sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey }) + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { + modifierKey, + }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */ " :shortcuts="$options.shortcuts.italic" icon="italic" /> <toolbar-button + v-if="!restrictedToolBarItems.includes('strikethrough')" tag="~~" :button-title=" + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), { - modifierKey, + modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */, }) " :shortcuts="$options.shortcuts.strikethrough" icon="strikethrough" /> <toolbar-button + v-if="!restrictedToolBarItems.includes('quote')" :prepend="true" :tag="tag" :button-title="__('Insert a quote')" @@ -266,30 +280,37 @@ export default { tag="[{text}](url)" tag-select="url" :button-title=" - sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey }) + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { + modifierKey, + }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */ " :shortcuts="$options.shortcuts.link" icon="link" /> <toolbar-button + v-if="!restrictedToolBarItems.includes('bullet-list')" :prepend="true" tag="- " :button-title="__('Add a bullet list')" icon="list-bulleted" /> <toolbar-button + v-if="!restrictedToolBarItems.includes('numbered-list')" :prepend="true" tag="1. " :button-title="__('Add a numbered list')" icon="list-numbered" /> <toolbar-button + v-if="!restrictedToolBarItems.includes('task-list')" :prepend="true" tag="- [ ] " :button-title="__('Add a task list')" icon="list-task" /> <toolbar-button + v-if="!restrictedToolBarItems.includes('collapsible-section')" :tag="mdCollapsibleSection" :prepend="true" tag-select="Click to expand" @@ -297,12 +318,14 @@ export default { icon="details-block" /> <toolbar-button + v-if="!restrictedToolBarItems.includes('table')" :tag="mdTable" :prepend="true" :button-title="__('Add a table')" icon="table" /> <toolbar-button + v-if="!restrictedToolBarItems.includes('full-screen')" class="js-zen-enter" :prepend="true" :button-title="__('Go full screen')" diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index f1c293c87f4..6c99a749edc 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -24,6 +24,11 @@ export default { required: false, default: true, }, + showCommentToolBar: { + type: Boolean, + required: false, + default: true, + }, }, computed: { hasQuickActionsDocsPath() { @@ -34,24 +39,33 @@ export default { </script> <template> - <div class="comment-toolbar clearfix"> + <div v-if="showCommentToolBar" class="comment-toolbar clearfix"> <div class="toolbar-text"> <template v-if="!hasQuickActionsDocsPath && markdownDocsPath"> - <gl-link :href="markdownDocsPath" target="_blank"> - {{ __('Markdown is supported') }} - </gl-link> + <gl-sprintf + :message=" + s__('MarkdownToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}') + " + > + <template #markdownDocsLink="{ content }"> + <gl-link :href="markdownDocsPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> </template> <template v-if="hasQuickActionsDocsPath && markdownDocsPath"> <gl-sprintf :message=" - __( - '%{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd} and %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd} are supported', + s__( + 'NoteToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}. For %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd}, type %{keyboardStart}/%{keyboardEnd}.', ) " > <template #markdownDocsLink="{ content }"> <gl-link :href="markdownDocsPath" target="_blank">{{ content }}</gl-link> </template> + <template #keyboard="{ content }"> + <kbd>{{ content }}</kbd> + </template> <template #quickActionsDocsLink="{ content }"> <gl-link :href="quickActionsDocsPath" target="_blank">{{ content }}</gl-link> </template> diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue index 3e796a73f72..e23721da223 100644 --- a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue +++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue @@ -90,7 +90,9 @@ export default { modal-id="upload-metric-modal" size="sm" :action-primary="actionPrimaryProps" - :action-cancel="{ text: $options.i18n.modalCancel }" + :action-cancel="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + text: $options.i18n.modalCancel, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" :title="$options.i18n.modalTitle" :visible="modalVisible" @hidden="clearInputs" diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue index 8eb8e52728d..bbbaaeb8a9e 100644 --- a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue +++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue @@ -159,7 +159,9 @@ export default { size="sm" :visible="modalVisible" :action-primary="deleteActionPrimaryProps" - :action-cancel="{ text: $options.i18n.modalCancel }" + :action-cancel="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + text: $options.i18n.modalCancel, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @primary.prevent="onDelete" @hidden="resetEditFields" > @@ -177,7 +179,9 @@ export default { modal-id="edit-metric-modal" size="sm" :action-primary="updateActionPrimaryProps" - :action-cancel="{ text: $options.i18n.modalCancel }" + :action-cancel="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + text: $options.i18n.modalCancel, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" :visible="editModalVisible" data-testid="metric-image-edit-modal" @hidden="resetEditFields" diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue index a069d1cd756..21212e82de4 100644 --- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue @@ -61,7 +61,9 @@ export default { v-for="(tab, i) in tabs" :key="i" :title-link-class="`js-${scope}-tab-${tab.scope} gl-display-inline-flex`" - :title-link-attributes="{ 'data-testid': `${scope}-tab-${tab.scope}` }" + :title-link-attributes="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + 'data-testid': `${scope}-tab-${tab.scope}`, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" :active="tab.isActive" @click="onTabClick(tab)" > diff --git a/app/assets/javascripts/vue_shared/components/paginated_list.vue b/app/assets/javascripts/vue_shared/components/paginated_list.vue index e19b8510399..ddc7a457b98 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_list.vue +++ b/app/assets/javascripts/vue_shared/components/paginated_list.vue @@ -29,7 +29,7 @@ export default { </template> <template #default="{ listItem, query }"> - <slot :listItem="listItem" :query="query"></slot> + <slot :list-item="listItem" :query="query"></slot> </template> </gl-paginated-list> </template> diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue index 6bb321713d5..a8b250f2041 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -32,7 +32,6 @@ export default { return { 'gl-border-t-transparent': !this.first && !this.selected, 'gl-border-t-gray-100': this.first && !this.selected, - 'gl-opacity-5': this.disabled, 'gl-border-b-gray-100': !this.selected, 'gl-bg-blue-50 gl-border-blue-200': this.selected, }; diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue index 767a108dde5..da68fe961a6 100644 --- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue +++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue @@ -116,6 +116,7 @@ export default { @clear="clearSearch" /> <gl-sorting + data-testid="registry-sort-dropdown" :text="sortText" :is-ascending="isSortAscending" @sortDirectionChange="onDirectionChange" diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue index d5493aa5a66..9eaaf7d1c18 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue @@ -50,6 +50,11 @@ export default { required: false, default: null, }, + defaultPlatformName: { + type: String, + required: false, + default: null, + }, }, apollo: { platforms: { @@ -64,9 +69,10 @@ export default { }); }, result() { - // Select first platform by default - if (this.platforms?.[0]) { - this.selectPlatform(this.platforms[0]); + if (this.platforms.length) { + // If it is set and available, select the defaultSelectedPlatform. + // Otherwise, select the first available platform + this.selectPlatform(this.defaultPlatform() || this.platforms[0]); } }, error() { @@ -138,6 +144,14 @@ export default { show() { this.$refs.modal.show(); }, + focusSelected() { + // By default the first platform always gets the focus, but when the `defaultPlatformName` + // property is present, any other platform might actually be selected. + this.$refs[this.selectedPlatformName]?.[0].$el.focus(); + }, + defaultPlatform() { + return this.platforms.find((platform) => platform.name === this.defaultPlatformName); + }, selectPlatform(platform) { this.selectedPlatform = platform; @@ -182,6 +196,8 @@ export default { :title="$options.i18n.installARunner" :action-secondary="$options.closeButton" v-bind="$attrs" + v-on="$listeners" + @shown="focusSelected" > <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)"> {{ $options.i18n.fetchError }} @@ -203,6 +219,7 @@ export default { <gl-button v-for="platform in platforms" :key="platform.name" + :ref="platform.name" :selected="selectedPlatform && selectedPlatform.name === platform.name" @click="selectPlatform(platform)" > diff --git a/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue b/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue new file mode 100644 index 00000000000..f50706b6de8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue @@ -0,0 +1,35 @@ +<script> +import { GlButtonGroup, GlButton } from '@gitlab/ui'; + +// TODO: We're planning to move this component to GitLab UI +// https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1787 +export default { + components: { + GlButtonGroup, + GlButton, + }, + props: { + options: { + type: Array, + required: true, + }, + value: { + type: [String, Number, Boolean], + required: true, + }, + }, +}; +</script> +<template> + <gl-button-group> + <gl-button + v-for="opt in options" + :key="opt.value" + :disabled="!!opt.disabled" + :selected="value === opt.value" + @click="$emit('input', opt.value)" + > + <slot name="button-content" v-bind="opt">{{ opt.text }}</slot> + </gl-button> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue index 12daaea8758..dfa2ca2d20c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue @@ -175,7 +175,7 @@ export default { :debounce="300" /> <div data-testid="content" class="dropdown-content"> - <gl-loading-icon v-if="projectsListLoading" size="md" class="gl-p-5" /> + <gl-loading-icon v-if="projectsListLoading" size="lg" class="gl-p-5" /> <ul v-else> <gl-dropdown-item v-for="project in projects" @@ -199,7 +199,7 @@ export default { > <gl-button category="primary" - variant="success" + variant="confirm" :disabled="!Boolean(selectedProject)" class="gl-text-center! issuable-move-button" @click="handleMoveClick" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue index 3ec33a653b8..2cccb8325f4 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue @@ -104,7 +104,7 @@ export default { <gl-button :disabled="disableCreate" category="primary" - variant="success" + variant="confirm" class="float-left d-flex align-items-center" @click="handleCreateClick" > diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index 623e7799493..134575b7a27 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -185,7 +185,7 @@ export default { <gl-loading-icon v-if="labelsFetchInProgress" class="labels-fetch-loading gl-align-items-center w-100 h-100" - size="md" + size="lg" /> <ul v-else class="list-unstyled gl-mb-0 gl-word-break-word"> <label-item diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue index 7989ad40b5a..e91a0489ef1 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue @@ -23,7 +23,7 @@ export default { </script> <template> - <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900"> + <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-font-weight-bold"> {{ __('Labels') }} <template v-if="allowLabelEdit"> <gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline /> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue index 88977652556..090bf9493bf 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue @@ -179,7 +179,7 @@ export default { <gl-button :disabled="disableCreate" category="primary" - variant="success" + variant="confirm" class="gl-display-flex gl-align-items-center" data-testid="create-button" @click="createLabel" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue index ae179ef93c7..f595e635f2c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue @@ -147,7 +147,7 @@ export default { <gl-loading-icon v-if="labelsFetchInProgress" class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full gl-mb-3" - size="md" + size="lg" /> <template v-else> <gl-dropdown-item diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue index 1b8e4bcfec6..c30ca5369ee 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue @@ -36,6 +36,9 @@ export default { return content; }, + firstLineClass() { + return { 'gl-mt-3!': this.number === 1 }; + }, }, methods: { wrapBidiChar(bidiChar) { @@ -56,10 +59,11 @@ export default { </script> <template> <div class="gl-display-flex"> - <div class="line-numbers gl-pt-0! gl-pb-0! gl-absolute gl-z-index-3"> + <div class="gl-p-0! gl-absolute gl-z-index-3 gl-border-r diff-line-num line-numbers"> <gl-link :id="`L${number}`" - class="file-line-num diff-line-num gl-user-select-none" + class="gl-user-select-none gl-ml-5 gl-pr-3 gl-shadow-none! file-line-num diff-line-num" + :class="firstLineClass" :to="`#L${number}`" :data-line-number="number" > @@ -68,7 +72,8 @@ export default { </div> <pre - class="code highlight gl-p-0! gl-w-full gl-overflow-visible! gl-ml-11!" + class="gl-p-0! gl-w-full gl-overflow-visible! gl-ml-11! gl-border-none! code highlight" + :class="firstLineClass" ><code><span :id="`LC${number}`" v-safe-html="formattedContent" :lang="language" class="line" data-testid="content"></span></code></pre> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index edf2229a9a1..ed87a202b15 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -2,6 +2,7 @@ import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui'; import LineHighlighter from '~/blob/line_highlighter'; import eventHub from '~/notes/event_hub'; +import languageLoader from '~/content_editor/services/highlight_js_language_loader'; import { ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK } from './constants'; import Chunk from './components/chunk.vue'; @@ -129,7 +130,7 @@ export default { let languageDefinition; try { - languageDefinition = await import(`highlight.js/lib/languages/${this.language}`); + languageDefinition = await languageLoader[this.language](); this.hljs.registerLanguage(this.language, languageDefinition.default); } catch (message) { this.$emit('error', message); diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue index e784bba6698..0d466df1b7f 100644 --- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue +++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue @@ -42,6 +42,6 @@ export default { :class="cssClass" :title="tooltipTitle(time)" :datetime="time" - ><slot :timeAgo="timeAgo">{{ timeAgo }}</slot></time + ><slot :time-ago="timeAgo">{{ timeAgo }}</slot></time > </template> diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue index 62de76e46b5..f62bf686f85 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 @@ -160,7 +160,7 @@ export default { > <gl-icon name="upload" :size="iconStyles.size" :class="iconStyles.class" /> <p class="gl-mb-0" data-testid="upload-text"> - <slot name="upload-text" :openFileUpload="openFileUpload"> + <slot name="upload-text" :open-file-upload="openFileUpload"> <gl-sprintf :message=" singleFileSelection diff --git a/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue new file mode 100644 index 00000000000..bc5e0cf10dd --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue @@ -0,0 +1,68 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; + +export default { + name: 'UsageBanner', + components: { + GlSkeletonLoader, + }, + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + }, + i18n: { + dependencyProxy: s__('UsageQuota|Dependency proxy'), + storageUsed: s__('UsageQuota|Storage used'), + dependencyProxyMessage: s__( + 'UsageQuota|Local proxy used for frequently-accessed upstream Docker images. %{linkStart}More information%{linkEnd}', + ), + }, + storageUsageQuotaHelpPage: helpPagePath('user/usage_quotas'), +}; +</script> +<template> + <div class="gl-display-flex gl-flex-direction-column"> + <div class="gl-display-flex gl-align-items-center gl-py-3"> + <div + class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-grow-1" + > + <div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1"> + <div + v-if="$slots['left-primary-text']" + class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0 gl-mb-4" + > + <slot name="left-primary-text"></slot> + </div> + <div + v-if="$slots['left-secondary-text']" + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1 gl-w-70p gl-md-max-w-70p" + > + <slot name="left-secondary-text"></slot> + </div> + </div> + <div + class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0" + > + <div + v-if="$slots['right-primary-text']" + class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6" + > + <slot name="right-primary-text"></slot> + </div> + <div + v-if="$slots['right-secondary-text']" + class="gl-display-flex gl-align-items-center gl-min-h-6" + > + <slot v-if="!loading" name="right-secondary-text"></slot> + <gl-skeleton-loader v-else :width="60" :lines="1" /> + </div> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index cac8f0a9aa5..ec7a7cd72ae 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -6,9 +6,13 @@ import { GlIcon, GlSafeHtmlDirective, GlSprintf, + GlButton, } from '@gitlab/ui'; +import { __ } from '~/locale'; import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; import { glEmojiTag } from '~/emoji'; +import createFlash from '~/flash'; +import { followUser, unfollowUser } from '~/rest_api'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; const MAX_SKELETON_LINES = 4; @@ -24,6 +28,7 @@ export default { UserAvatarImage, UserNameWithStatus, GlSprintf, + GlButton, }, directives: { SafeHtml: GlSafeHtmlDirective, @@ -38,6 +43,16 @@ export default { required: true, default: null, }, + placement: { + type: String, + required: false, + default: 'top', + }, + }, + data() { + return { + toggleFollowLoading: false, + }; }, computed: { statusHtml() { @@ -59,6 +74,59 @@ export default { availabilityStatus() { return this.user?.status?.availability || ''; }, + isNotCurrentUser() { + return !this.userIsLoading && this.user.username !== gon.current_username; + }, + shouldRenderToggleFollowButton() { + return this.isNotCurrentUser && typeof this.user?.isFollowed !== 'undefined'; + }, + toggleFollowButtonText() { + if (this.toggleFollowLoading) return null; + + return this.user?.isFollowed ? __('Unfollow') : __('Follow'); + }, + toggleFollowButtonVariant() { + return this.user?.isFollowed ? 'default' : 'confirm'; + }, + }, + methods: { + async toggleFollow() { + if (this.user.isFollowed) { + this.unfollow(); + } else { + this.follow(); + } + }, + async follow() { + this.toggleFollowLoading = true; + try { + await followUser(this.user.id); + this.$emit('follow'); + } catch (error) { + createFlash({ + message: __('An error occurred while trying to follow this user, please try again.'), + error, + captureError: true, + }); + } finally { + this.toggleFollowLoading = false; + } + }, + async unfollow() { + this.toggleFollowLoading = true; + try { + await unfollowUser(this.user.id); + this.$emit('unfollow'); + } catch (error) { + createFlash({ + message: __('An error occurred while trying to unfollow this user, please try again.'), + error, + captureError: true, + }); + } finally { + this.toggleFollowLoading = false; + } + }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; @@ -66,12 +134,24 @@ export default { <template> <!-- 200ms delay so not every mouseover triggers Popover --> - <gl-popover :target="target" :delay="200" boundary="viewport" placement="top"> + <gl-popover :target="target" :delay="200" :placement="placement" boundary="viewport"> <div class="gl-p-3 gl-line-height-normal gl-display-flex" data-testid="user-popover"> - <div class="gl-p-2 flex-shrink-1"> - <user-avatar-image :img-src="user.avatarUrl" :size="64" css-classes="gl-mr-3!" /> + <div + class="gl-p-2 flex-shrink-1 gl-display-flex gl-flex-direction-column align-items-center gl-w-70p" + > + <user-avatar-image :img-src="user.avatarUrl" :size="64" css-classes="gl-m-0!" /> + <div v-if="shouldRenderToggleFollowButton" class="gl-mt-3"> + <gl-button + :variant="toggleFollowButtonVariant" + :loading="toggleFollowLoading" + size="small" + data-testid="toggle-follow-button" + @click="toggleFollow" + >{{ toggleFollowButtonText }}</gl-button + > + </div> </div> - <div class="gl-p-2 gl-w-full gl-min-w-0"> + <div class="gl-w-full gl-min-w-0 gl-word-break-word"> <template v-if="userIsLoading"> <gl-skeleton-loader :lines="$options.maxSkeletonLines" @@ -94,7 +174,7 @@ export default { <div class="gl-text-gray-500"> <div v-if="user.bio" class="gl-display-flex gl-mb-2"> <gl-icon name="profile" class="gl-flex-shrink-0" /> - <span ref="bio" class="gl-ml-2 gl-overflow-hidden">{{ user.bio }}</span> + <span ref="bio" class="gl-ml-2">{{ user.bio }}</span> </div> <div v-if="user.workInformation" class="gl-display-flex gl-mb-2"> <gl-icon name="work" class="gl-flex-shrink-0" /> diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue index 9df5254155e..91f20863089 100644 --- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue +++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue @@ -298,7 +298,7 @@ export default { <gl-loading-icon v-if="isLoading" data-testid="loading-participants" - size="md" + size="lg" class="gl-absolute gl-left-0 gl-top-0 gl-right-0" /> <template v-else> diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index 9cb66f6e65f..3ebeec4a50b 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -1,4 +1,5 @@ -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; +import { IssuableType, WorkspaceType } from '~/issues/constants'; const INTERVALS = { minute: 'minute', @@ -66,3 +67,14 @@ export const getTimeWindow = (timeWindowName) => export const AVATAR_SHAPE_OPTION_CIRCLE = 'circle'; export const AVATAR_SHAPE_OPTION_RECT = 'rect'; + +export const confidentialityInfoText = (workspaceType, issuableType) => + sprintf( + __( + 'Only %{workspaceType} members with at least Reporter role can view or be notified about this %{issuableType}.', + ), + { + workspaceType: workspaceType === WorkspaceType.project ? __('project') : __('group'), + issuableType: issuableType === IssuableType.Issue ? __('issue') : __('epic'), + }, + ); diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue index c216a05bdb0..0758cb507e9 100644 --- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue @@ -1,5 +1,5 @@ <script> -import { GlForm, GlFormInput } from '@gitlab/ui'; +import { GlForm, GlFormInput, GlFormGroup } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; @@ -9,6 +9,7 @@ export default { components: { GlForm, GlFormInput, + GlFormGroup, MarkdownField, LabelsSelect, }, @@ -37,6 +38,7 @@ export default { selectedLabels: [], }; }, + computed: {}, methods: { handleUpdateSelectedLabels(labels) { if (labels.length) { @@ -52,12 +54,15 @@ export default { <div data-testid="issuable-title" class="form-group row"> <label for="issuable-title" class="col-form-label col-sm-2">{{ __('Title') }}</label> <div class="col-sm-10"> - <gl-form-input - id="issuable-title" - v-model="issuableTitle" - :autofocus="true" - :placeholder="__('Title')" - /> + <gl-form-group :description="__('Maximum of 255 characters')"> + <gl-form-input + id="issuable-title" + v-model="issuableTitle" + maxlength="255" + :autofocus="true" + :placeholder="__('Title')" + /> + </gl-form-group> </div> </div> <div data-testid="issuable-description" class="form-group row"> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index 8008b85bbdb..6453290f6ea 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -55,7 +55,7 @@ export default { return createdSecondsAgo < SECONDS_IN_DAY; }, author() { - return this.issuable.author; + return this.issuable.author || {}; }, webUrl() { return this.issuable.gitlabWebUrl || this.issuable.webUrl; @@ -215,7 +215,7 @@ export default { <span class="gl-display-none gl-sm-display-inline"> <span aria-hidden="true">·</span> <span class="issuable-authored gl-mr-3"> - <gl-sprintf :message="__('created %{timeAgo} by %{author}')"> + <gl-sprintf v-if="author.name" :message="__('created %{timeAgo} by %{author}')"> <template #timeAgo> <span v-gl-tooltip.bottom @@ -241,6 +241,17 @@ export default { </gl-link> </template> </gl-sprintf> + <gl-sprintf v-else :message="__('created %{timeAgo}')"> + <template #timeAgo> + <span + v-gl-tooltip.bottom + :title="tooltipTitle(issuable.createdAt)" + data-testid="issuable-created-at" + > + {{ createdAt }} + </span> + </template> + </gl-sprintf> </span> <slot name="timeframe"></slot> </span> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index 20f178dfb7d..8b293b2e9f6 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -168,6 +168,11 @@ export default { required: false, default: '', }, + syncFilterAndSort: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -282,6 +287,7 @@ export default { :sort-options="sortOptions" :initial-filter-value="initialFilterValue" :initial-sort-by="initialSortBy" + :sync-filter-and-sort="syncFilterAndSort" :show-checkbox="showBulkEditSidebar" :checkbox-checked="allIssuablesChecked" class="gl-flex-grow-1 gl-border-t-none row-content-block" diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue index ee7e113af72..649dbd6576b 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue @@ -1,14 +1,23 @@ <script> -import { GlIcon, GlButton, GlTooltipDirective, GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui'; +import { + GlIcon, + GlBadge, + GlButton, + GlTooltipDirective, + GlAvatarLink, + GlAvatarLabeled, +} from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isExternal } from '~/lib/utils/url_utility'; import { n__, sprintf } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { IssuableStates } from '~/vue_shared/issuable/list/constants'; export default { components: { GlIcon, + GlBadge, GlButton, GlAvatarLink, GlAvatarLabeled, @@ -26,6 +35,11 @@ export default { type: Object, required: true, }, + issuableState: { + type: String, + required: false, + default: '', + }, statusBadgeClass: { type: String, required: false, @@ -36,6 +50,11 @@ export default { required: false, default: '', }, + statusIconClass: { + type: String, + required: false, + default: '', + }, blocked: { type: Boolean, required: false, @@ -53,6 +72,9 @@ export default { }, }, computed: { + badgeVariant() { + return this.issuableState === IssuableStates.Opened ? 'success' : 'info'; + }, authorId() { return getIdFromGraphQLId(`${this.author.id}`); }, @@ -71,6 +93,9 @@ export default { { completedCount, count }, ); }, + hasTasks() { + return this.taskCompletionStatus.count > 0; + }, }, mounted() { this.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button'); @@ -88,10 +113,15 @@ export default { <template> <div class="detail-page-header"> <div class="detail-page-header-body"> - <div data-testid="status" class="issuable-status-box status-box" :class="statusBadgeClass"> - <gl-icon v-if="statusIcon" :name="statusIcon" class="d-block d-sm-none" /> - <span class="d-none d-sm-block"><slot name="status-badge"></slot></span> - </div> + <gl-badge + data-testid="status" + class="issuable-status-badge gl-mr-3" + :class="statusBadgeClass" + :variant="badgeVariant" + > + <gl-icon v-if="statusIcon" :name="statusIcon" :class="statusIconClass" /> + <span class="gl-display-none gl-sm-display-block"><slot name="status-badge"></slot></span> + </gl-badge> <div class="issuable-meta gl-display-flex gl-align-items-center d-md-inline-block"> <div v-if="blocked || confidential" class="gl-display-inline-block"> <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline"> @@ -128,7 +158,7 @@ export default { <strong class="author d-sm-none d-inline">@{{ author.username }}</strong> </gl-avatar-link> <span - v-if="taskCompletionStatus" + v-if="taskCompletionStatus && hasTasks" data-testid="task-status" class="gl-display-none gl-md-display-block gl-lg-display-inline-block" >{{ taskStatusString }}</span diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue index 8849af2a52e..c165ee91c59 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue @@ -27,6 +27,11 @@ export default { required: false, default: '', }, + statusIconClass: { + type: String, + required: false, + default: '', + }, enableEdit: { type: Boolean, required: false, @@ -102,8 +107,10 @@ export default { <template> <div class="issuable-show-container" data-qa-selector="issuable_show_container"> <issuable-header + :issuable-state="issuable.state" :status-badge-class="statusBadgeClass" :status-icon="statusIcon" + :status-icon-class="statusIconClass" :blocked="issuable.blocked" :confidential="issuable.confidential" :created-at="issuable.createdAt" @@ -122,6 +129,7 @@ export default { :issuable="issuable" :status-badge-class="statusBadgeClass" :status-icon="statusIcon" + :status-icon-class="statusIconClass" :enable-edit="enableEdit" :enable-autocomplete="enableAutocomplete" :enable-autosave="enableAutosave" diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue index 45941174a62..47f05a2cee2 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue @@ -86,7 +86,7 @@ export default { > <p data-testid="status" - class="issuable-status-box status-box gl-my-0" + class="issuable-status-box status-box gl-white-space-nowrap gl-my-0" :class="statusBadgeClass" > <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" /> diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js index c5f41d81167..2a0256548a8 100644 --- a/app/assets/javascripts/vue_shared/mixins/timeago.js +++ b/app/assets/javascripts/vue_shared/mixins/timeago.js @@ -1,4 +1,4 @@ -import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; +import { formatDate, getTimeago, timeagoLanguageCode } from '~/lib/utils/datetime_utility'; /** * Mixin with time ago methods used in some vue components @@ -8,7 +8,7 @@ export default { timeFormatted(time) { const timeago = getTimeago(); - return timeago.format(time); + return timeago.format(time, timeagoLanguageCode); }, tooltipTitle(time) { diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/section_layout.vue b/app/assets/javascripts/vue_shared/security_configuration/components/section_layout.vue new file mode 100644 index 00000000000..6045d75ac11 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_configuration/components/section_layout.vue @@ -0,0 +1,34 @@ +<script> +import SectionLoader from './section_loader.vue'; + +export default { + name: 'SectionLayout', + components: { + SectionLoader, + }, + props: { + heading: { + type: String, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> + +<template> + <div class="row gl-m-0 gl-border-b gl-line-height-20 gl-py-6"> + <div class="col-lg-4 gl-pl-0 gl-pr-9"> + <h2 class="gl-font-size-h2 gl-mt-0">{{ heading }}</h2> + <slot name="description"></slot> + </div> + <div class="col-lg-8 gl-pr-0 gl-pl-0"> + <section-loader v-if="isLoading" /> + <slot v-else name="features"></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/section_loader.vue b/app/assets/javascripts/vue_shared/security_configuration/components/section_loader.vue new file mode 100644 index 00000000000..b15e25b0943 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_configuration/components/section_loader.vue @@ -0,0 +1,35 @@ +<script> +import { GlCard, GlSkeletonLoader } from '@gitlab/ui'; + +export default { + name: 'SectionLoader', + components: { + GlCard, + GlSkeletonLoader, + }, +}; +</script> + +<template> + <div> + <gl-skeleton-loader :width="1248" :height="180"> + <rect x="0" y="0" width="100" height="15" rx="4" /> + <rect x="0" y="24" width="460" height="32" rx="4" /> + <rect x="0" y="71" width="100" height="15" rx="4" /> + <rect x="0" y="95" width="460" height="72" rx="4" /> + </gl-skeleton-loader> + <gl-card v-for="i in 2" :key="i" class="gl-mb-5"> + <template #header> + <gl-skeleton-loader :width="1248" :height="15"> + <rect x="0" y="0" width="300" height="15" rx="4" /> + </gl-skeleton-loader> + </template> + <gl-skeleton-loader :width="1248" :height="15"> + <rect x="0" y="0" width="600" height="15" rx="4" /> + </gl-skeleton-loader> + <gl-skeleton-loader :width="1248" :height="15"> + <rect x="0" y="0" width="300" height="15" rx="4" /> + </gl-skeleton-loader> + </gl-card> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js index 458bacce915..6a4f671abb9 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js @@ -90,7 +90,7 @@ const createStatusMessage = ({ reportType, status, total }) => { if (status) { message = __('%{reportType} %{status}'); } else if (!total) { - message = __('%{reportType} detected %{totalStart}no%{totalEnd} vulnerabilities.'); + message = __('%{reportType} detected no %{totalStart}new%{totalEnd} vulnerabilities.'); } else { message = __( '%{reportType} detected %{totalStart}%{total}%{totalEnd} potential %{vulnMessage}', diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue index a93bda326de..90f6230ef72 100644 --- a/app/assets/javascripts/whats_new/components/feature.vue +++ b/app/assets/javascripts/whats_new/components/feature.vue @@ -37,9 +37,11 @@ export default { <template> <div class="gl-py-6 gl-px-6 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"> <gl-link + v-if="feature.image_url" :href="feature.url" target="_blank" class="gl-display-block" + data-testid="whats-new-image-link" data-track-action="click_whats_new_item" :data-track-label="feature.title" :data-track-property="feature.url" @@ -67,9 +69,11 @@ export default { v-for="packageName in feature.packages" :key="packageName" size="md" - class="whats-new-item-badge gl-mr-2" + variant="tier" + icon="license" + class="gl-mr-2" > - <gl-icon name="license" />{{ packageName }} + {{ packageName }} </gl-badge> </div> <div diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue new file mode 100644 index 00000000000..0b6c1a75bb2 --- /dev/null +++ b/app/assets/javascripts/work_items/components/item_state.vue @@ -0,0 +1,62 @@ +<script> +import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { STATE_OPEN, STATE_CLOSED } from '../constants'; + +export default { + i18n: { + status: __('Status'), + }, + states: [ + { + value: STATE_OPEN, + text: __('Open'), + }, + { + value: STATE_CLOSED, + text: __('Closed'), + }, + ], + components: { + GlFormGroup, + GlFormSelect, + }, + props: { + state: { + type: String, + required: true, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + currentState() { + return this.$options.states[this.state]; + }, + }, + methods: { + setState(newState) { + if (newState !== this.state) { + this.$emit('changed', newState); + } + }, + }, + labelId: 'work-item-state-select', +}; +</script> + +<template> + <gl-form-group :label="$options.i18n.status" :label-for="$options.labelId"> + <gl-form-select + :id="$options.labelId" + :value="state" + :options="$options.states" + :disabled="loading" + class="gl-w-auto" + @change="setState" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue index 40b6fcdd204..31e4a932c5a 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -1,7 +1,7 @@ <script> import { GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; -import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql'; +import Tracking from '~/tracking'; export default { i18n: { @@ -15,55 +15,36 @@ export default { directives: { GlModal: GlModalDirective, }, + mixins: [Tracking.mixin({ label: 'actions_menu' })], props: { workItemId: { type: String, required: false, default: null, }, - canUpdate: { + canDelete: { type: Boolean, required: false, default: false, }, }, - emits: ['workItemDeleted', 'error'], + emits: ['deleteWorkItem'], methods: { - deleteWorkItem() { - this.$apollo - .mutate({ - mutation: deleteWorkItemMutation, - variables: { - input: { - id: this.workItemId, - }, - }, - }) - .then(({ data: { workItemDelete, errors } }) => { - if (errors?.length) { - throw new Error(errors[0].message); - } - - if (workItemDelete?.errors.length) { - throw new Error(workItemDelete.errors[0]); - } - - this.$emit('workItemDeleted'); - }) - .catch((e) => { - this.$emit( - 'error', - e.message || - s__('WorkItem|Something went wrong when deleting the work item. Please try again.'), - ); - }); + handleDeleteWorkItem() { + this.track('click_delete_work_item'); + this.$emit('deleteWorkItem'); + }, + handleCancelDeleteWorkItem({ trigger }) { + if (trigger !== 'ok') { + this.track('cancel_delete_work_item'); + } }, }, }; </script> <template> - <div v-if="canUpdate"> + <div v-if="canDelete"> <gl-dropdown icon="ellipsis_v" text-sr-only @@ -81,7 +62,8 @@ export default { :title="$options.i18n.deleteWorkItem" :ok-title="$options.i18n.deleteWorkItem" ok-variant="danger" - @ok="deleteWorkItem" + @ok="handleDeleteWorkItem" + @hide="handleCancelDeleteWorkItem" > {{ s__( diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index f2fb1e3ccbc..4222ffe42fe 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,15 +1,20 @@ <script> -import { GlAlert } from '@gitlab/ui'; +import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; import { i18n } from '../constants'; import workItemQuery from '../graphql/work_item.query.graphql'; import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql'; +import WorkItemActions from './work_item_actions.vue'; +import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; export default { i18n, components: { GlAlert, + GlSkeletonLoader, + WorkItemActions, WorkItemTitle, + WorkItemState, }, props: { workItemId: { @@ -49,9 +54,18 @@ export default { }, }, computed: { + workItemLoading() { + return this.$apollo.queries.workItem.loading; + }, workItemType() { return this.workItem.workItemType?.name; }, + canUpdate() { + return this.workItem?.userPermissions?.updateWorkItem; + }, + canDelete() { + return this.workItem?.userPermissions?.deleteWorkItem; + }, }, }; </script> @@ -62,12 +76,35 @@ export default { {{ error }} </gl-alert> - <work-item-title - :loading="$apollo.queries.workItem.loading" - :work-item-id="workItem.id" - :work-item-title="workItem.title" - :work-item-type="workItemType" - @error="error = $event" - /> + <div v-if="workItemLoading" class="gl-max-w-26 gl-py-5"> + <gl-skeleton-loader :height="65" :width="240"> + <rect width="240" height="20" x="5" y="0" rx="4" /> + <rect width="100" height="20" x="5" y="45" rx="4" /> + </gl-skeleton-loader> + </div> + <template v-else> + <div class="gl-display-flex"> + <work-item-title + :work-item-id="workItem.id" + :work-item-title="workItem.title" + :work-item-type="workItemType" + class="gl-mr-5" + @error="error = $event" + @updated="$emit('workItemUpdated')" + /> + <work-item-actions + :work-item-id="workItem.id" + :can-delete="canDelete" + class="gl-ml-auto gl-mt-5" + @deleteWorkItem="$emit('deleteWorkItem')" + @error="error = $event" + /> + </div> + <work-item-state + :work-item="workItem" + @error="error = $event" + @updated="$emit('workItemUpdated')" + /> + </template> </section> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index a79091fb8b2..172a40a6e56 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -1,42 +1,87 @@ <script> -import { GlAlert, GlButton, GlModal } from '@gitlab/ui'; -import WorkItemActions from './work_item_actions.vue'; +import { GlAlert, GlModal } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import deleteWorkItemFromTaskMutation from '../graphql/delete_task_from_work_item.mutation.graphql'; import WorkItemDetail from './work_item_detail.vue'; export default { components: { GlAlert, - GlButton, GlModal, WorkItemDetail, - WorkItemActions, }, props: { - canUpdate: { - type: Boolean, + workItemId: { + type: String, required: false, - default: false, + default: null, }, - visible: { - type: Boolean, - required: true, + issueGid: { + type: String, + required: false, + default: '', }, - workItemId: { + lockVersion: { + type: Number, + required: false, + default: null, + }, + lineNumberStart: { + type: String, + required: false, + default: null, + }, + lineNumberEnd: { type: String, required: false, default: null, }, }, - emits: ['workItemDeleted', 'close'], + emits: ['workItemDeleted', 'workItemUpdated', 'close'], data() { return { error: undefined, }; }, methods: { - handleWorkItemDeleted() { - this.$emit('workItemDeleted'); - this.closeModal(); + deleteWorkItem() { + this.$apollo + .mutate({ + mutation: deleteWorkItemFromTaskMutation, + variables: { + input: { + id: this.issueGid, + lockVersion: this.lockVersion, + taskData: { + id: this.workItemId, + lineNumberStart: Number(this.lineNumberStart), + lineNumberEnd: Number(this.lineNumberEnd), + }, + }, + }, + }) + .then( + ({ + data: { + workItemDeleteTask: { + workItem: { descriptionHtml }, + errors, + }, + }, + }) => { + if (errors?.length) { + throw new Error(errors[0].message); + } + + this.$emit('workItemDeleted', descriptionHtml); + this.$refs.modal.hide(); + }, + ) + .catch((e) => { + this.error = + e.message || + s__('WorkItem|Something went wrong when deleting the work item. Please try again.'); + }); }, closeModal() { this.error = ''; @@ -45,37 +90,31 @@ export default { setErrorMessage(message) { this.error = message; }, + show() { + this.$refs.modal.show(); + }, }, }; </script> <template> - <gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="closeModal"> - <template #modal-header> - <div class="gl-w-full gl-display-flex gl-align-items-center gl-justify-content-end"> - <h2 class="modal-title gl-mr-auto">{{ s__('WorkItem|Work Item') }}</h2> - <work-item-actions - :work-item-id="workItemId" - :can-update="canUpdate" - @workItemDeleted="handleWorkItemDeleted" - @error="setErrorMessage" - /> - <gl-button category="tertiary" icon="close" :aria-label="__('Close')" @click="closeModal" /> - </div> - </template> + <gl-modal ref="modal" hide-footer size="lg" modal-id="work-item-detail-modal" @hide="closeModal"> <gl-alert v-if="error" variant="danger" @dismiss="error = false"> {{ error }} </gl-alert> - <work-item-detail :work-item-id="workItemId" /> + <work-item-detail + :work-item-id="workItemId" + @deleteWorkItem="deleteWorkItem" + @workItemUpdated="$emit('workItemUpdated')" + /> </gl-modal> </template> <style> -/* hide the existing close button until we can do it - * with https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2710 +/* hide the existing modal header */ -#work-item-detail-modal .modal-header > .gl-button { +#work-item-detail-modal .modal-header { display: none; } </style> diff --git a/app/assets/javascripts/work_items/components/work_item_state.vue b/app/assets/javascripts/work_items/components/work_item_state.vue new file mode 100644 index 00000000000..51db4c804eb --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_state.vue @@ -0,0 +1,98 @@ +<script> +import * as Sentry from '@sentry/browser'; +import Tracking from '~/tracking'; +import { + i18n, + STATE_OPEN, + STATE_CLOSED, + STATE_EVENT_CLOSE, + STATE_EVENT_REOPEN, +} from '../constants'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import ItemState from './item_state.vue'; + +export default { + components: { + ItemState, + }, + mixins: [Tracking.mixin()], + props: { + workItem: { + type: Object, + required: true, + }, + }, + data() { + return { + updateInProgress: false, + }; + }, + computed: { + workItemType() { + return this.workItem.workItemType?.name; + }, + tracking() { + return { + category: 'workItems:show', + label: 'item_state', + property: `type_${this.workItemType}`, + }; + }, + }, + methods: { + async updateWorkItemState(newState) { + const stateEventMap = { + [STATE_OPEN]: STATE_EVENT_REOPEN, + [STATE_CLOSED]: STATE_EVENT_CLOSE, + }; + + const stateEvent = stateEventMap[newState]; + + await this.updateWorkItem(stateEvent); + }, + async updateWorkItem(updatedState) { + if (!updatedState) { + return; + } + + this.updateInProgress = true; + + try { + this.track('updated_state'); + + const { + data: { workItemUpdate }, + } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItem.id, + stateEvent: updatedState, + }, + }, + }); + + if (workItemUpdate?.errors?.length) { + throw new Error(workItemUpdate.errors[0]); + } + + this.$emit('updated'); + } catch (error) { + this.$emit('error', i18n.updateError); + Sentry.captureException(error); + } + + this.updateInProgress = false; + }, + }, +}; +</script> + +<template> + <item-state + v-if="workItem.state" + :state="workItem.state" + :loading="updateInProgress" + @changed="updateWorkItemState" + /> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_title.vue b/app/assets/javascripts/work_items/components/work_item_title.vue index 88a825853cc..d2e6d3c0bbf 100644 --- a/app/assets/javascripts/work_items/components/work_item_title.vue +++ b/app/assets/javascripts/work_items/components/work_item_title.vue @@ -1,5 +1,4 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; import Tracking from '~/tracking'; import { i18n } from '../constants'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; @@ -7,16 +6,10 @@ import ItemTitle from './item_title.vue'; export default { components: { - GlLoadingIcon, ItemTitle, }, mixins: [Tracking.mixin()], props: { - loading: { - type: Boolean, - required: false, - default: false, - }, workItemId: { type: String, required: false, @@ -59,6 +52,7 @@ export default { }, }); this.track('updated_title'); + this.$emit('updated'); } catch { this.$emit('error', i18n.updateError); } @@ -68,6 +62,5 @@ export default { </script> <template> - <gl-loading-icon v-if="loading" class="gl-mt-3" size="md" /> - <item-title v-else :title="workItemTitle" @title-changed="updateTitle" /> + <item-title :title="workItemTitle" @title-changed="updateTitle" /> </template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index d3bcaf0f95f..e914500108f 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -1,6 +1,14 @@ import { s__ } from '~/locale'; +export const STATE_OPEN = 'OPEN'; +export const STATE_CLOSED = 'CLOSED'; + +export const STATE_EVENT_REOPEN = 'REOPEN'; +export const STATE_EVENT_CLOSE = 'CLOSE'; + export const i18n = { fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'), updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), }; + +export const DEFAULT_MODAL_TYPE = 'Task'; diff --git a/app/assets/javascripts/work_items/graphql/delete_task_from_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/delete_task_from_work_item.mutation.graphql new file mode 100644 index 00000000000..32c07ed48c7 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/delete_task_from_work_item.mutation.graphql @@ -0,0 +1,9 @@ +mutation workItemDeleteTask($input: WorkItemDeleteTaskInput!) { + workItemDeleteTask(input: $input) { + workItem { + id + descriptionHtml + } + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index 2707d6bb790..e25fd102699 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -1,8 +1,14 @@ fragment WorkItem on WorkItem { id title + state + description workItemType { id name } + userPermissions { + deleteWorkItem + updateWorkItem + } } diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index 1d3dae0649d..3b46fed97ec 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -1,6 +1,6 @@ #import "./work_item.fragment.graphql" -query workItem($id: ID!) { +query workItem($id: WorkItemID!) { workItem(id: $id) { ...WorkItem } diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 10fae9b9cc0..e39b0d6a353 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -5,7 +5,7 @@ import { createApolloProvider } from './graphql/provider'; export const initWorkItemsRoot = () => { const el = document.querySelector('#js-work-items'); - const { fullPath } = el.dataset; + const { fullPath, issuesListPath } = el.dataset; return new Vue({ el, @@ -13,6 +13,7 @@ export const initWorkItemsRoot = () => { apolloProvider: createApolloProvider(), provide: { fullPath, + issuesListPath, }, render(createElement) { return createElement(App); diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index a95da80ac95..04c6a61689c 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -6,6 +6,7 @@ import workItemQuery from '../graphql/work_item.query.graphql'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql'; import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; +import { DEFAULT_MODAL_TYPE } from '../constants'; import ItemTitle from '../components/item_title.vue'; @@ -77,6 +78,13 @@ export default { text: node.name, })); }, + result() { + if (!this.selectedWorkItemType && this.isModal) { + this.selectedWorkItemType = this.formOptions.find( + (options) => options.text === DEFAULT_MODAL_TYPE, + )?.value; + } + }, error() { this.error = this.$options.fetchTypesErrorText; }, @@ -115,20 +123,15 @@ export default { }, }, update(store, { data: { workItemCreate } }) { - const { id, title, workItemType } = workItemCreate.workItem; + const { workItem } = workItemCreate; store.writeQuery({ query: workItemQuery, variables: { - id, + id: workItem.id, }, data: { - workItem: { - __typename: 'WorkItem', - id, - title, - workItemType, - }, + workItem, }, }); }, @@ -185,11 +188,11 @@ export default { <form @submit.prevent="createWorkItem"> <gl-alert v-if="error" variant="danger" @dismiss="error = null">{{ error }}</gl-alert> <div :class="{ 'gl-px-5': isModal }" data-testid="content"> - <item-title :title="title" data-testid="title-input" @title-input="handleTitleInput" /> + <item-title :title="initialTitle" data-testid="title-input" @title-input="handleTitleInput" /> <div> <gl-loading-icon v-if="$apollo.queries.workItemTypes.loading" - size="md" + size="lg" data-testid="loading-types" /> <gl-form-select diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index b8f2bcff25d..6dc3dc3b3c9 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -1,26 +1,70 @@ <script> +import { GlAlert } from '@gitlab/ui'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; import WorkItemDetail from '../components/work_item_detail.vue'; +import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql'; export default { components: { + GlAlert, WorkItemDetail, }, + inject: ['issuesListPath'], props: { id: { type: String, required: true, }, }, + data() { + return { + error: '', + }; + }, computed: { gid() { return convertToGraphQLId(TYPE_WORK_ITEM, this.id); }, }, + methods: { + deleteWorkItem() { + this.$apollo + .mutate({ + mutation: deleteWorkItemMutation, + variables: { + input: { + id: this.gid, + }, + }, + }) + .then(({ data: { workItemDelete, errors } }) => { + if (errors?.length) { + throw new Error(errors[0].message); + } + + if (workItemDelete?.errors.length) { + throw new Error(workItemDelete.errors[0]); + } + + this.$toast.show(s__('WorkItem|Work item deleted')); + visitUrl(this.issuesListPath); + }) + .catch((e) => { + this.error = + e.message || + s__('WorkItem|Something went wrong when deleting the work item. Please try again.'); + }); + }, + }, }; </script> <template> - <work-item-detail :work-item-id="gid" /> + <div> + <gl-alert v-if="error" variant="danger" @dismiss="error = ''">{{ error }}</gl-alert> + <work-item-detail :work-item-id="gid" @deleteWorkItem="deleteWorkItem" /> + </div> </template> diff --git a/app/assets/javascripts/work_items/router/index.js b/app/assets/javascripts/work_items/router/index.js index 142fab8cfa6..2b39a298720 100644 --- a/app/assets/javascripts/work_items/router/index.js +++ b/app/assets/javascripts/work_items/router/index.js @@ -1,8 +1,10 @@ +import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueRouter from 'vue-router'; import { joinPaths } from '~/lib/utils/url_utility'; import { routes } from './routes'; +Vue.use(GlToast); Vue.use(VueRouter); export function createRouter(fullPath) { diff --git a/app/assets/javascripts/work_items_hierarchy/components/app.vue b/app/assets/javascripts/work_items_hierarchy/components/app.vue index 621cfe5bace..779bd27516a 100644 --- a/app/assets/javascripts/work_items_hierarchy/components/app.vue +++ b/app/assets/javascripts/work_items_hierarchy/components/app.vue @@ -1,6 +1,6 @@ <script> import { GlBanner } from '@gitlab/ui'; -import Cookies from 'js-cookie'; +import Cookies from '~/lib/utils/cookies'; import { parseBoolean } from '~/lib/utils/common_utils'; import RESPONSE from '../static_response'; import { WORK_ITEMS_SURVEY_COOKIE_NAME, workItemTypes } from '../constants'; |