diff options
Diffstat (limited to 'app/assets/javascripts')
689 files changed, 13286 insertions, 7027 deletions
diff --git a/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue index c716afbbcf0..4a7c12e5e51 100644 --- a/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue +++ b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue @@ -18,14 +18,17 @@ export default { reportAbusePath: { default: '', }, + }, + props: { reportedUserId: { - default: '', + type: Number, + required: true, }, reportedFromUrl: { + type: String, + required: false, default: '', }, - }, - props: { showDrawer: { type: Boolean, required: true, @@ -39,8 +42,8 @@ export default { }, categoryOptions: [ { value: 'spam', text: s__("ReportAbuse|They're posting spam.") }, - { value: 'offensive', text: s__("ReportAbuse|They're being offsensive or abusive.") }, - { value: 'phishing', text: s__("ReportAbuse|They're phising.") }, + { value: 'offensive', text: s__("ReportAbuse|They're being offensive or abusive.") }, + { value: 'phishing', text: s__("ReportAbuse|They're phishing.") }, { value: 'crypto', text: s__("ReportAbuse|They're crypto mining.") }, { value: 'credentials', @@ -53,13 +56,22 @@ export default { data() { return { selected: '', + mounted: false, }; }, computed: { drawerOffsetTop() { + // avoid calculating this in advance because it causes layout thrashing + // https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396 + if (!this.showDrawer) return '0'; return getContentWrapperHeight('.content-wrapper'); }, }, + mounted() { + // this is required for the component to properly animate + // when it is shown with v-if + this.mounted = true; + }, methods: { closeDrawer() { this.$emit('close-drawer'); @@ -71,7 +83,7 @@ export default { <gl-drawer :header-height="drawerOffsetTop" :z-index="300" - :open="showDrawer" + :open="showDrawer && mounted" @close="closeDrawer" > <template #title> @@ -94,7 +106,7 @@ export default { data-testid="input-referer" /> - <gl-form-group :label="$options.i18n.label"> + <gl-form-group :label="$options.i18n.label" label-class="gl-text-black-normal"> <gl-form-radio-group v-model="selected" :options="$options.categoryOptions" diff --git a/app/assets/javascripts/abuse_reports/components/links_to_spam_input.vue b/app/assets/javascripts/abuse_reports/components/links_to_spam_input.vue new file mode 100644 index 00000000000..02fe131553c --- /dev/null +++ b/app/assets/javascripts/abuse_reports/components/links_to_spam_input.vue @@ -0,0 +1,68 @@ +<script> +import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'LinksToSpamInput', + components: { + GlButton, + GlFormGroup, + GlFormInput, + }, + i18n: { + label: s__('ReportAbuse|Link to spam'), + description: s__('ReportAbuse|URL of this user posting spam'), + addAnotherText: s__('ReportAbuse|Add another link'), + }, + props: { + previousLinks: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + links: this.previousLinks.length > 0 ? this.previousLinks : [''], + }; + }, + methods: { + addAnotherInput() { + this.links.push(''); + }, + }, +}; +</script> +<template> + <div> + <template v-for="(link, index) in links"> + <div :key="index" class="row"> + <div class="col-lg-8"> + <gl-form-group class="gl-mt-5"> + <template #label> + <div class="gl-pb-2"> + {{ $options.i18n.label }} + </div> + <div class="gl-font-weight-normal"> + {{ $options.i18n.description }} + </div> + </template> + <gl-form-input + v-model.trim="links[index]" + type="url" + name="abuse_report[links_to_spam][]" + autocomplete="off" + /> + </gl-form-group> + </div> + </div> + </template> + <div class="row"> + <div class="col-lg-8"> + <gl-button variant="link" icon="plus" class="gl-float-right" @click="addAnotherInput"> + {{ $options.i18n.addAnotherText }} + </gl-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/abuse_reports/index.js b/app/assets/javascripts/abuse_reports/index.js new file mode 100644 index 00000000000..fff4ad8daa0 --- /dev/null +++ b/app/assets/javascripts/abuse_reports/index.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import LinksToSpamInput from './components/links_to_spam_input.vue'; + +export const initLinkToSpam = () => { + const el = document.getElementById('js-links-to-spam'); + + if (!el) return false; + + const { links } = el.dataset; + + return new Vue({ + el, + name: 'LinksToSpamRoot', + render(createElement) { + return createElement(LinksToSpamInput, { + props: { + previousLinks: JSON.parse(links), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue index 7cc4a0d349d..8e335dbda32 100644 --- a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue +++ b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue @@ -42,7 +42,7 @@ export default { <gl-collapsible-listbox v-model="selected" :items="databases" - right + placement="right" :toggle-text="selectedDatabase" toggle-aria-labelled-by="label" @select="selectDatabase" diff --git a/app/assets/javascripts/admin/topics/components/topic_select.vue b/app/assets/javascripts/admin/topics/components/topic_select.vue index 8bf5be1afd1..9f42aa27097 100644 --- a/app/assets/javascripts/admin/topics/components/topic_select.vue +++ b/app/assets/javascripts/admin/topics/components/topic_select.vue @@ -1,22 +1,14 @@ <script> -import { - GlAvatarLabeled, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, -} from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { s__, n__ } from '~/locale'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql'; export default { components: { GlAvatarLabeled, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, + GlCollapsibleListbox, }, props: { selectedTopic: { @@ -48,15 +40,13 @@ export default { return { topics: [], search: '', + selected: null, }; }, computed: { loading() { return this.$apollo.queries.topics.loading; }, - isResultEmpty() { - return this.topics.length === 0; - }, dropdownText() { if (Object.keys(this.selectedTopic).length) { return this.selectedTopic.name; @@ -64,10 +54,35 @@ export default { return this.$options.i18n.dropdownText; }, + items() { + return this.topics.map(({ id, title, name, avatarUrl }) => ({ + value: id, + text: title, + secondaryText: name, + icon: avatarUrl, + })); + }, + searchSummary() { + return n__('TopicSelect|%d topic found', 'TopicSelect|%d topics found', this.topics.length); + }, + labelId() { + if (!this.labelText) { + return null; + } + + return uniqueId('topic-listbox-label-'); + }, }, methods: { - selectTopic(topic) { - this.$emit('click', topic); + onSelect(topicId) { + const topicObj = this.topics.find((topic) => topic.id === topicId); + + if (!topicObj) return; + + this.$emit('click', topicObj); + }, + onSearch(query) { + this.search = query; }, }, i18n: { @@ -81,26 +96,34 @@ export default { <template> <div> - <label v-if="labelText">{{ labelText }}</label> - <gl-dropdown block :text="dropdownText"> - <gl-search-box-by-type - v-model="search" - :is-loading="loading" - :placeholder="$options.i18n.searchPlaceholder" - /> - <gl-dropdown-item v-for="topic in topics" :key="topic.id" @click="selectTopic(topic)"> + <label v-if="labelText" :id="labelId">{{ labelText }}</label> + <gl-collapsible-listbox + v-model="selected" + block + searchable + is-check-centered + :items="items" + :toggle-text="dropdownText" + :searching="loading" + :search-placeholder="$options.i18n.searchPlaceholder" + :no-results-text="$options.i18n.emptySearchResult" + :toggle-aria-labelled-by="labelId" + @select="onSelect" + @search="onSearch" + > + <template #list-item="{ item: { text, secondaryText, icon } }"> <gl-avatar-labeled - :label="topic.title" - :sub-label="topic.name" - :src="topic.avatarUrl" - :entity-name="topic.name" + :label="text" + :sub-label="secondaryText" + :src="icon" + :entity-name="secondaryText" :size="32" :shape="$options.AVATAR_SHAPE_OPTION_RECT" /> - </gl-dropdown-item> - <gl-dropdown-text v-if="isResultEmpty && !loading"> - <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> - </gl-dropdown-text> - </gl-dropdown> + </template> + <template #search-summary-sr-only> + {{ searchSummary }} + </template> + </gl-collapsible-listbox> </div> </template> diff --git a/app/assets/javascripts/airflow/dags/components/dags.vue b/app/assets/javascripts/airflow/dags/components/dags.vue new file mode 100644 index 00000000000..88eb3fd5aba --- /dev/null +++ b/app/assets/javascripts/airflow/dags/components/dags.vue @@ -0,0 +1,111 @@ +<script> +import { GlTableLite, GlEmptyState, GlPagination, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { formatDate } from '~/lib/utils/datetime/date_format_utility'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; + +export default { + name: 'AirflowDags', + components: { + GlTableLite, + GlEmptyState, + IncubationAlert, + GlPagination, + TimeAgo, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + dags: { + type: Array, + required: true, + }, + pagination: { + type: Object, + required: true, + }, + }, + computed: { + fields() { + return [ + { key: 'dag_name', label: this.$options.i18n.dagLabel }, + { key: 'schedule', label: this.$options.scheduleLabel }, + { key: 'next_run', label: this.$options.nextRunLabel }, + { key: 'is_active', label: this.$options.isActiveLabel }, + { key: 'is_paused', label: this.$options.isPausedLabel }, + { key: 'fileloc', label: this.$options.fileLocLabel }, + ]; + }, + hasPagination() { + return this.dags.length > 0; + }, + prevPage() { + return this.pagination.page > 1 ? this.pagination.page - 1 : null; + }, + nextPage() { + return !this.pagination.isLastPage ? this.pagination.page + 1 : null; + }, + emptyState() { + return { + svgPath: '/assets/illustrations/empty-state/empty-dag-md.svg', + }; + }, + }, + methods: { + generateLink(page) { + return setUrlParams({ page }); + }, + formatDate(dateString) { + return formatDate(new Date(dateString)); + }, + }, + i18n: { + emptyStateLabel: s__('Airflow|There are no DAGs to show'), + emptyStateDescription: s__( + 'Airflow|Either the Airflow instance does not contain DAGs or has yet to be configured', + ), + dagLabel: s__('Airflow|DAG'), + scheduleLabel: s__('Airflow|Schedule'), + nextRunLabel: s__('Airflow|Next run'), + isActiveLabel: s__('Airflow|Is active'), + isPausedLabel: s__('Airflow|Is paused'), + fileLocLabel: s__('Airflow|DAG file location'), + featureName: s__('Airflow|GitLab Airflow integration'), + }, + linkToFeedbackIssue: + 'https://gitlab.com/gitlab-org/incubation-engineering/airflow/meta/-/issues/2', +}; +</script> + +<template> + <div> + <incubation-alert + :feature-name="$options.i18n.featureName" + :link-to-feedback-issue="$options.linkToFeedbackIssue" + /> + <gl-empty-state + v-if="!dags.length" + :title="$options.i18n.emptyStateLabel" + :description="$options.i18n.emptyStateDescription" + :svg-path="emptyState.svgPath" + /> + <gl-table-lite v-else :items="dags" :fields="fields" class="gl-mt-0!"> + <template #cell(next_run)="data"> + <time-ago v-gl-tooltip.hover :time="data.value" :title="formatDate(data.value)" /> + </template> + </gl-table-lite> + <gl-pagination + v-if="hasPagination" + :value="pagination.page" + :prev-page="prevPage" + :next-page="nextPage" + :total-items="pagination.totalItems" + :per-page="pagination.perPage" + :link-gen="generateLink" + align="center" + /> + </div> +</template> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_form.vue index a0d5cb7f4c3..38bcdef3e04 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_form.vue @@ -119,7 +119,10 @@ export default { </gl-form-group> <gl-form-group class="gl-pl-0 gl-mb-5"> - <gl-form-checkbox v-model="sendEmailEnabled"> + <gl-form-checkbox + v-model="sendEmailEnabled" + data-qa-selector="enable_email_notification_checkbox" + > <span>{{ $options.i18n.sendEmail.label }}</span> </gl-form-checkbox> </gl-form-group> diff --git a/app/assets/javascripts/analytics/shared/components/metric_popover.vue b/app/assets/javascripts/analytics/shared/components/metric_popover.vue index 8d90e7b2392..373a7fac6f7 100644 --- a/app/assets/javascripts/analytics/shared/components/metric_popover.vue +++ b/app/assets/javascripts/analytics/shared/components/metric_popover.vue @@ -1,5 +1,6 @@ <script> import { GlPopover, GlLink, GlIcon } from '@gitlab/ui'; +import { METRIC_POPOVER_LABEL } from '../constants'; export default { name: 'MetricPopover', @@ -19,34 +20,34 @@ export default { }, }, computed: { - metricLinks() { - return this.metric.links?.filter((link) => !link.docs_link) || []; + metricLink() { + return this.metric.links?.find((link) => !link.docs_link); }, docsLink() { return this.metric.links?.find((link) => link.docs_link); }, }, + metricPopoverLabel: METRIC_POPOVER_LABEL, }; </script> <template> - <gl-popover :target="target" placement="bottom"> + <gl-popover :target="target" placement="top"> <template #title> - <span class="gl-display-block gl-text-left" data-testid="metric-label">{{ - metric.label - }}</span> + <div + class="gl-display-flex gl-justify-content-space-between gl-text-right gl-py-1 gl-align-items-center" + > + <span data-testid="metric-label">{{ metric.label }}</span> + <gl-link + v-if="metricLink" + :href="metricLink.url" + class="gl-font-sm gl-font-weight-normal" + data-testid="metric-link" + >{{ $options.metricPopoverLabel }} + <gl-icon name="chart" /> + </gl-link> + </div> </template> - <div - v-for="(link, idx) in metricLinks" - :key="`link-${idx}`" - class="gl-display-flex gl-justify-content-space-between gl-text-right gl-py-1" - data-testid="metric-link" - > - <span>{{ link.label }}</span> - <gl-link :href="link.url" class="gl-font-sm"> - {{ link.name }} - </gl-link> - </div> <span v-if="metric.description" data-testid="metric-description">{{ metric.description }}</span> <gl-link v-if="docsLink" diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js index c62736d55a8..7ced658f483 100644 --- a/app/assets/javascripts/analytics/shared/constants.js +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -1,5 +1,6 @@ import { masks } from '~/lib/dateformat'; import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const DATE_RANGE_LIMIT = 180; export const PROJECTS_PER_PAGE = 50; @@ -12,8 +13,103 @@ export const dateFormats = { month: 'mmmm', }; -// Some content is duplicated due to backward compatibility. -// It will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/350614 in 14.9 +export const METRIC_POPOVER_LABEL = s__('ValueStreamAnalytics|View details'); + +export const KEY_METRICS = { + LEAD_TIME: 'lead_time', + CYCLE_TIME: 'cycle_time', + ISSUES: 'issues', + COMMITS: 'commits', + DEPLOYS: 'deploys', +}; + +export const DORA_METRICS = { + DEPLOYMENT_FREQUENCY: 'deployment_frequency', + LEAD_TIME_FOR_CHANGES: 'lead_time_for_changes', + TIME_TO_RESTORE_SERVICE: 'time_to_restore_service', + CHANGE_FAILURE_RATE: 'change_failure_rate', +}; + +export const VSA_METRICS_GROUPS = [ + { + key: 'key_metrics', + title: s__('ValueStreamAnalytics|Key metrics'), + keys: Object.values(KEY_METRICS), + }, + { + key: 'dora_metrics', + title: s__('ValueStreamAnalytics|DORA metrics'), + keys: Object.values(DORA_METRICS), + }, +]; + +export const METRIC_TOOLTIPS = { + [DORA_METRICS.DEPLOYMENT_FREQUENCY]: { + description: s__( + 'ValueStreamAnalytics|Average number of deployments to production per day. This metric measures how often value is delivered to end users.', + ), + groupLink: '-/analytics/ci_cd?tab=deployment-frequency', + projectLink: '-/pipelines/charts?chart=deployment-frequency', + docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'deployment-frequency' }), + }, + [DORA_METRICS.LEAD_TIME_FOR_CHANGES]: { + description: s__( + 'ValueStreamAnalytics|The time to successfully deliver a commit into production. This metric reflects the efficiency of CI/CD pipelines.', + ), + groupLink: '-/analytics/ci_cd?tab=lead-time', + projectLink: '-/pipelines/charts?chart=lead-time', + docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'lead-time-for-changes' }), + }, + [DORA_METRICS.TIME_TO_RESTORE_SERVICE]: { + description: s__( + 'ValueStreamAnalytics|The time it takes an organization to recover from a failure in production.', + ), + groupLink: '-/analytics/ci_cd?tab=time-to-restore-service', + projectLink: '-/pipelines/charts?chart=time-to-restore-service', + docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'time-to-restore-service' }), + }, + [DORA_METRICS.CHANGE_FAILURE_RATE]: { + description: s__( + 'ValueStreamAnalytics|Percentage of deployments that cause an incident in production.', + ), + groupLink: '-/analytics/ci_cd?tab=change-failure-rate', + projectLink: '-/pipelines/charts?chart=change-failure-rate', + docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'change-failure-rate' }), + }, + [KEY_METRICS.LEAD_TIME]: { + description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'), + groupLink: '-/analytics/value_stream_analytics', + projectLink: '-/value_stream_analytics', + docsLink: helpPagePath('user/analytics/value_stream_analytics', { + anchor: 'view-the-lead-time-and-cycle-time-for-issues', + }), + }, + [KEY_METRICS.CYCLE_TIME]: { + description: s__( + "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.", + ), + groupLink: '-/analytics/value_stream_analytics', + projectLink: '-/value_stream_analytics', + docsLink: helpPagePath('user/analytics/value_stream_analytics', { + anchor: 'view-the-lead-time-and-cycle-time-for-issues', + }), + }, + [KEY_METRICS.ISSUES]: { + description: s__('ValueStreamAnalytics|Number of new issues created.'), + groupLink: '-/issues_analytics', + projectLink: '-/analytics/issues_analytics', + docsLink: helpPagePath('user/analytics/issue_analytics'), + }, + [KEY_METRICS.DEPLOYS]: { + description: s__('ValueStreamAnalytics|Total number of deploys to production.'), + groupLink: '-/analytics/productivity_analytics', + projectLink: '-/analytics/merge_request_analytics', + docsLink: helpPagePath('user/analytics/merge_request_analytics'), + }, +}; + +// TODO: Remove this once the migration to METRIC_TOOLTIPS is complete +// https://gitlab.com/gitlab-org/gitlab/-/issues/388067 export const METRICS_POPOVER_CONTENT = { lead_time: { description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'), @@ -47,19 +143,3 @@ export const METRICS_POPOVER_CONTENT = { ), }, }; - -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/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js index 71b719d1ed2..aafbf642766 100644 --- a/app/assets/javascripts/analytics/shared/utils.js +++ b/app/assets/javascripts/analytics/shared/utils.js @@ -1,5 +1,4 @@ import { flatten } from 'lodash'; -import { hideFlash } from '~/flash'; import dateFormat from '~/lib/dateformat'; import { slugify } from '~/lib/utils/text_utility'; import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; @@ -74,10 +73,8 @@ export const getDataZoomOption = ({ }; export const removeFlash = (type = 'alert') => { - const flashEl = document.querySelector(`.flash-${type}`); - if (flashEl) { - hideFlash(flashEl); - } + // flash-warning don't have dismiss button. + document.querySelector(`.flash-${type} .js-close`)?.click(); }; /** diff --git a/app/assets/javascripts/api/environments_api.js b/app/assets/javascripts/api/environments_api.js new file mode 100644 index 00000000000..9912b1ab696 --- /dev/null +++ b/app/assets/javascripts/api/environments_api.js @@ -0,0 +1,15 @@ +import axios from '../lib/utils/axios_utils'; +import { buildApiUrl } from './api_utils'; + +export const STOP_STALE_ENVIRONMENTS_PATH = '/api/:version/projects/:id/environments/stop_stale'; + +export function stopStaleEnvironments(projectId, before, query, options) { + const url = buildApiUrl(STOP_STALE_ENVIRONMENTS_PATH).replace(':id', projectId); + const defaults = { + before: before.toISOString(), + }; + + return axios.post(url, null, { + params: Object.assign(defaults, options), + }); +} diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js index e859160c2e7..1b216e6f721 100644 --- a/app/assets/javascripts/api/groups_api.js +++ b/app/assets/javascripts/api/groups_api.js @@ -4,6 +4,8 @@ import { buildApiUrl } from './api_utils'; const GROUP_PATH = '/api/:version/groups/:id'; const GROUPS_PATH = '/api/:version/groups.json'; +const GROUP_MEMBERS_PATH = '/api/:version/groups/:id/members'; +const GROUP_ALL_MEMBERS_PATH = '/api/:version/groups/:id/members/all'; const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups'; const GROUP_TRANSFER_LOCATIONS_PATH = 'api/:version/groups/:id/transfer_locations'; @@ -45,3 +47,10 @@ export const getGroupTransferLocations = (groupId, params = {}) => { return axios.get(url, { params: { ...defaultParams, ...params } }); }; + +export const getGroupMembers = (groupId, inherited = false) => { + const path = inherited ? GROUP_ALL_MEMBERS_PATH : GROUP_MEMBERS_PATH; + const url = buildApiUrl(path).replace(':id', groupId); + + return axios.get(url); +}; diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js index 5b5abbdf50b..5c0d101ef5b 100644 --- a/app/assets/javascripts/api/projects_api.js +++ b/app/assets/javascripts/api/projects_api.js @@ -3,6 +3,8 @@ import axios from '../lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; const PROJECTS_PATH = '/api/:version/projects.json'; +const PROJECT_MEMBERS_PATH = '/api/:version/projects/:id/members'; +const PROJECT_ALL_MEMBERS_PATH = '/api/:version/projects/:id/members/all'; const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_members/:project_id'; const PROJECT_REPOSITORY_SIZE_PATH = '/api/:version/projects/:id/repository_size'; const PROJECT_TRANSFER_LOCATIONS_PATH = 'api/:version/projects/:id/transfer_locations'; @@ -19,6 +21,10 @@ export function getProjects(query, options, callback = () => {}) { defaults.membership = true; } + if (gon.features.fullPathProjectSearch && query?.includes('/')) { + defaults.search_namespaces = true; + } + return axios .get(url, { params: Object.assign(defaults, options), @@ -50,3 +56,10 @@ export const getTransferLocations = (projectId, params = {}) => { return axios.get(url, { params: { ...defaultParams, ...params } }); }; + +export const getProjectMembers = (projectId, inherited = false) => { + const path = inherited ? PROJECT_ALL_MEMBERS_PATH : PROJECT_MEMBERS_PATH; + const url = buildApiUrl(path).replace(':id', projectId); + + return axios.get(url); +}; diff --git a/app/assets/javascripts/artifacts/components/app.vue b/app/assets/javascripts/artifacts/components/app.vue new file mode 100644 index 00000000000..3a07be65341 --- /dev/null +++ b/app/assets/javascripts/artifacts/components/app.vue @@ -0,0 +1,65 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import getBuildArtifactsSizeQuery from '../graphql/queries/get_build_artifacts_size.query.graphql'; +import { PAGE_TITLE, TOTAL_ARTIFACTS_SIZE, SIZE_UNKNOWN } from '../constants'; +import JobArtifactsTable from './job_artifacts_table.vue'; + +export default { + name: 'ArtifactsApp', + components: { + GlSkeletonLoader, + JobArtifactsTable, + }, + inject: ['projectPath'], + apollo: { + buildArtifactsSize: { + query: getBuildArtifactsSizeQuery, + variables() { + return { projectPath: this.projectPath }; + }, + update({ + project: { + statistics: { buildArtifactsSize }, + }, + }) { + return buildArtifactsSize; + }, + }, + }, + data() { + return { + buildArtifactsSize: null, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.buildArtifactsSize.loading; + }, + humanReadableArtifactsSize() { + return numberToHumanSize(this.buildArtifactsSize); + }, + }, + i18n: { + PAGE_TITLE, + TOTAL_ARTIFACTS_SIZE, + SIZE_UNKNOWN, + }, +}; +</script> +<template> + <div> + <h1 class="page-title gl-font-size-h-display gl-mb-0" data-testid="artifacts-page-title"> + {{ $options.i18n.PAGE_TITLE }} + </h1> + <div class="gl-mb-6" data-testid="build-artifacts-size"> + <gl-skeleton-loader v-if="isLoading" :lines="1" /> + <template v-else> + <strong>{{ $options.i18n.TOTAL_ARTIFACTS_SIZE }}</strong> + <span v-if="buildArtifactsSize !== null">{{ humanReadableArtifactsSize }}</span> + <span v-else>{{ $options.i18n.SIZE_UNKNOWN }}</span> + </template> + </div> + <job-artifacts-table /> + </div> +</template> diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/artifacts/constants.js index 28fd81fa641..da562b03bf8 100644 --- a/app/assets/javascripts/artifacts/constants.js +++ b/app/assets/javascripts/artifacts/constants.js @@ -1,5 +1,9 @@ import { __, s__, n__, sprintf } from '~/locale'; +export const PAGE_TITLE = s__('Artifacts|Artifacts'); +export const TOTAL_ARTIFACTS_SIZE = s__('Artifacts|Total artifacts size'); +export const SIZE_UNKNOWN = __('Unknown'); + export const JOB_STATUS_GROUP_SUCCESS = 'success'; export const STATUS_BADGE_VARIANTS = { diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql b/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql new file mode 100644 index 00000000000..23da65ad0bb --- /dev/null +++ b/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql @@ -0,0 +1,8 @@ +query getBuildArtifactsSize($projectPath: ID!) { + project(fullPath: $projectPath) { + id + statistics { + buildArtifactsSize + } + } +} diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql index 89a24d7891e..5737f9f8e8d 100644 --- a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql +++ b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql @@ -10,13 +10,12 @@ query getJobArtifacts( project(fullPath: $projectPath) { id jobs( - statuses: [SUCCESS, FAILED] + withArtifacts: true first: $firstPageSize last: $lastPageSize after: $nextPageCursor before: $prevPageCursor ) { - count nodes { id name diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/artifacts/index.js index e0b2ab2bf47..a62b3daa961 100644 --- a/app/assets/javascripts/artifacts/index.js +++ b/app/assets/javascripts/artifacts/index.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; -import JobArtifactsTable from './components/job_artifacts_table.vue'; +import App from './components/app.vue'; Vue.use(VueApollo); @@ -27,6 +27,6 @@ export const initArtifactsTable = () => { canDestroyArtifacts: parseBoolean(canDestroyArtifacts), artifactsManagementFeedbackImagePath, }, - render: (createElement) => createElement(JobArtifactsTable), + render: (createElement) => createElement(App), }); }; diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index 5bb310afac7..cc524c71c1e 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -1,22 +1,17 @@ <script> -import { GlButton, GlBadge } from '@gitlab/ui'; +import { GlBadge } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import NoteableNote from '~/notes/components/noteable_note.vue'; -import PublishButton from './publish_button.vue'; export default { components: { NoteableNote, - PublishButton, - GlButton, GlBadge, }, directives: { SafeHtml, }, - mixins: [glFeatureFlagMixin()], props: { draft: { type: Object, @@ -89,8 +84,7 @@ export default { :note="draft" :line="line" :discussion-root="true" - :class="{ 'gl-mb-0!': glFeatures.mrReviewSubmitComment }" - class="draft-note-component draft-note" + class="draft-note-component draft-note gl-mb-0!" @handleEdit="handleEditing" @cancelForm="handleNotEditing" @updateSuccess="handleNotEditing" @@ -109,23 +103,6 @@ export default { v-safe-html:[$options.safeHtmlConfig]="draftCommands" class="referenced-commands draft-note-commands" ></div> - - <p v-if="!glFeatures.mrReviewSubmitComment" class="draft-note-actions d-flex"> - <publish-button - :show-count="true" - :should-publish="false" - category="secondary" - :disabled="isPublishingDraft(draft.id)" - /> - <gl-button - :disabled="isPublishing" - :loading="isPublishingDraft(draft.id)" - class="gl-ml-3" - @click="publishNow" - > - {{ __('Add comment now') }} - </gl-button> - </p> </template> </noteable-note> </template> diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue index ba5cc0d1a76..4ac0c8c4894 100644 --- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -1,31 +1,37 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlIcon, GlDisclosureDropdown, GlButton } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; import PreviewItem from './preview_item.vue'; import DraftsCount from './drafts_count.vue'; export default { components: { - GlDropdown, - GlDropdownItem, GlIcon, PreviewItem, DraftsCount, + GlDisclosureDropdown, + GlButton, }, - mixins: [glFeatureFlagMixin()], computed: { ...mapState('diffs', ['viewDiffsFileByFile']), ...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']), ...mapGetters(['getNoteableData']), + listItems() { + const sortedDraftCount = this.sortedDrafts.length - 1; + return this.sortedDrafts.map((item, index) => ({ + text: item.id.toString(), + action: () => { + this.onClickDraft(item); + }, + last: index === sortedDraftCount, + ...item, + })); + }, }, methods: { ...mapActions('diffs', ['setCurrentFileHash']), ...mapActions('batchComments', ['scrollToDraft']), - isLast(index) { - return index === this.sortedDrafts.length - 1; - }, isOnLatestDiff(draft) { return draft.position?.head_sha === this.getNoteableData.diff_head_sha; }, @@ -47,23 +53,23 @@ export default { </script> <template> - <gl-dropdown - :header-text="n__('%d pending comment', '%d pending comments', draftsCount)" - dropup - data-qa-selector="review_preview_dropdown" - > - <template #button-content> - {{ __('Pending comments') }} - <drafts-count v-if="glFeatures.mrReviewSubmitComment" variant="neutral" /> - <gl-icon class="dropdown-chevron" name="chevron-up" /> + <gl-disclosure-dropdown :items="listItems" dropup data-qa-selector="review_preview_dropdown"> + <template #toggle> + <gl-button + >{{ __('Pending comments') }} <drafts-count variant="neutral" /><gl-icon + class="dropdown-chevron" + name="chevron-up" + /></gl-button> + </template> + + <template #header> + <p class="gl-dropdown-header-top"> + {{ n__('%d pending comment', '%d pending comments', draftsCount) }} + </p> + </template> + + <template #list-item="{ item }"> + <preview-item :draft="item" :is-last="item.last" @click="onClickDraft(item)" /> </template> - <gl-dropdown-item - v-for="(draft, index) in sortedDrafts" - :key="draft.id" - data-testid="preview-item" - @click="onClickDraft(draft)" - > - <preview-item :draft="draft" :is-last="isLast(index)" /> - </gl-dropdown-item> - </gl-dropdown> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue deleted file mode 100644 index d4fc4ad744a..00000000000 --- a/app/assets/javascripts/batch_comments/components/publish_button.vue +++ /dev/null @@ -1,52 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; -import DraftsCount from './drafts_count.vue'; - -export default { - components: { - GlButton, - DraftsCount, - }, - props: { - showCount: { - type: Boolean, - required: false, - default: false, - }, - category: { - type: String, - required: false, - default: 'primary', - }, - variant: { - type: String, - required: false, - default: 'confirm', - }, - }, - computed: { - ...mapState('batchComments', ['isPublishing']), - }, - methods: { - ...mapActions('batchComments', ['publishReview']), - onClick() { - this.publishReview(); - }, - }, -}; -</script> - -<template> - <gl-button - :loading="isPublishing" - class="js-publish-draft-button" - data-qa-selector="submit_review_button" - :category="category" - :variant="variant" - @click="onClick" - > - {{ __('Submit review') }} - <drafts-count v-if="showCount" /> - </gl-button> -</template> diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue index 3cd1a2525e9..798ab301c90 100644 --- a/app/assets/javascripts/batch_comments/components/review_bar.vue +++ b/app/assets/javascripts/batch_comments/components/review_bar.vue @@ -3,13 +3,11 @@ import { mapActions, mapGetters } from 'vuex'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '../constants'; import PreviewDropdown from './preview_dropdown.vue'; -import PublishButton from './publish_button.vue'; import SubmitDropdown from './submit_dropdown.vue'; export default { components: { PreviewDropdown, - PublishButton, SubmitDropdown, }, mixins: [glFeatureFlagMixin()], @@ -42,8 +40,7 @@ export default { data-qa-selector="review_bar_content" > <preview-dropdown /> - <publish-button v-if="!glFeatures.mrReviewSubmitComment" class="gl-ml-3" show-count /> - <submit-dropdown v-else /> + <submit-dropdown /> </div> </nav> </div> diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js index 0c81ae63f21..970864eef74 100644 --- a/app/assets/javascripts/behaviors/copy_code.js +++ b/app/assets/javascripts/behaviors/copy_code.js @@ -8,7 +8,7 @@ class CopyCodeButton extends HTMLElement { this.for = uniqueId('code-'); const target = this.parentNode.querySelector('pre'); - if (!target) return; + if (!target || this.closest('.suggestions')) return; target.setAttribute('id', this.for); diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 220064e6673..1d36661ee63 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -7,7 +7,6 @@ import { loadStartupCSS } from './load_startup_css'; import initCopyAsGFM from './markdown/copy_as_gfm'; import './quick_submit'; import './requires_input'; -import initSelect2Dropdowns from './select2'; import initPageShortcuts from './shortcuts'; import './toggler_behavior'; import './preview_markdown'; @@ -21,7 +20,6 @@ initCopyToClipboard(); initPageShortcuts(); initCollapseSidebarOnWindowResize(); -initSelect2Dropdowns(); window.requestIdleCallback( () => { diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js index 7852a909160..b1bf6ebcb13 100644 --- a/app/assets/javascripts/behaviors/markdown/render_math.js +++ b/app/assets/javascripts/behaviors/markdown/render_math.js @@ -1,17 +1,19 @@ +import { escape } from 'lodash'; import { spriteIcon } from '~/lib/utils/common_utils'; import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; -import { s__ } from '~/locale'; +import { s__, sprintf } from '~/locale'; import { unrestrictedPages } from './constants'; -// Renders math using KaTeX in any element with the -// `js-render-math` class +// Renders math using KaTeX in an element. // -// ### Example Markup -// -// <code class="js-render-math"></div> +// Typically for elements with the `js-render-math` class such as +// <code class="js-render-math"></code> // +// See app/assets/javascripts/behaviors/markdown/render_gfm.js const MAX_MATH_CHARS = 1000; +const MAX_MACRO_EXPANSIONS = 1000; +const MAX_USER_SPECIFIED_EMS = 20; const MAX_RENDER_TIME_MS = 2000; // Wait for the browser to reflow the layout. Reflowing SVG takes time. @@ -69,17 +71,28 @@ class SafeMathRenderer { codeElement.className = 'code'; codeElement.textContent = el.textContent; + codeElement.dataset.mathStyle = el.dataset.mathStyle; const { parentNode } = el; parentNode.replaceChild(wrapperElement, el); + let message; + if (text.length > MAX_MATH_CHARS) { + message = sprintf( + s__( + 'math|This math block exceeds %{maxMathChars} characters, and may cause performance issues on this page.', + ), + { maxMathChars: MAX_MATH_CHARS }, + ); + } else { + message = s__('math|Displaying this math block may cause performance issues on this page.'); + } + const html = ` <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-math-container js-lazy-render-math-container fade show" role="alert"> - ${spriteIcon('warning', 'text-warning-600 s16 gl-alert-icon')} + ${spriteIcon('warning', 'gl-text-orange-600 s16 gl-alert-icon')} <div class="display-flex gl-alert-content"> - <div>${s__( - 'math|Displaying this math block may cause performance issues on this page', - )}</div> + <div>${message}</div> <div class="gl-alert-actions"> <button class="js-lazy-render-math btn gl-alert-action btn-confirm btn-md gl-button">Display anyway</button> </div> @@ -116,8 +129,10 @@ class SafeMathRenderer { displayContainer.innerHTML = this.katex.renderToString(text, { displayMode: el.dataset.mathStyle === 'display', throwOnError: true, - maxSize: 20, - maxExpand: 20, + maxSize: MAX_USER_SPECIFIED_EMS, + // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111107 for + // reasoning behind this value + maxExpand: MAX_MACRO_EXPANSIONS, trust: (context) => // this config option restores the KaTeX pre-v0.11.0 // behavior of allowing certain commands and protocols @@ -127,8 +142,17 @@ class SafeMathRenderer { }); } catch (e) { // Don't show a flash for now because it would override an existing flash message - el.textContent = s__('math|There was an error rendering this math block'); - // el.style.color = '#d00'; + if (e.message.match(/Too many expansions/)) { + // this is controlled by the maxExpand parameter + el.textContent = s__('math|Too many expansions. Consider using multiple math blocks.'); + } else { + // According to https://katex.org/docs/error.html, we need to ensure that + // the error message is escaped. + el.textContent = sprintf( + s__('math|There was an error rendering this math block. %{katexMessage}'), + { katexMessage: escape(e.message) }, + ); + } el.className = 'katex-error'; } diff --git a/app/assets/javascripts/behaviors/select2.js b/app/assets/javascripts/behaviors/select2.js deleted file mode 100644 index 1f222d8c1f6..00000000000 --- a/app/assets/javascripts/behaviors/select2.js +++ /dev/null @@ -1,30 +0,0 @@ -import $ from 'jquery'; -import { loadCSSFile } from '../lib/utils/css_utils'; - -export default () => { - const $select2Elements = $('select.select2'); - if ($select2Elements.length) { - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(() => { - // eslint-disable-next-line promise/no-nesting - loadCSSFile(gon.select2_css_path) - .then(() => { - $select2Elements.select2({ - width: 'resolve', - minimumResultsForSearch: 10, - dropdownAutoWidth: true, - }); - - // Close select2 on escape - $('.js-select2').on('select2-close', () => { - requestAnimationFrame(() => { - $('.select2-container-active').removeClass('select2-container-active'); - $(':focus').blur(); - }); - }); - }) - .catch(() => {}); - }) - .catch(() => {}); - } -}; diff --git a/app/assets/javascripts/behaviors/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts.js index 7352be0dbd5..12fdb2e2981 100644 --- a/app/assets/javascripts/behaviors/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts.js @@ -28,7 +28,10 @@ export default function initPageShortcuts() { // TODO: replace this whitelist with something more automated/maintainable if (page && !pagesWithCustomShortcuts.includes(page)) { import(/* webpackChunkName: 'shortcutsBundle' */ './shortcuts/shortcuts') - .then(({ default: Shortcuts }) => new Shortcuts()) + .then(({ default: Shortcuts }) => { + const shortcuts = new Shortcuts(); + window.toggleShortcutsHelp = shortcuts.onToggleHelp; + }) .catch(() => {}); } return false; diff --git a/app/assets/javascripts/blob/components/table_contents.vue b/app/assets/javascripts/blob/components/table_contents.vue index b3410b94b98..28e81b83713 100644 --- a/app/assets/javascripts/blob/components/table_contents.vue +++ b/app/assets/javascripts/blob/components/table_contents.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; function getHeaderNumber(el) { return parseInt(el.tagName.match(/\d+/)[0], 10); @@ -7,8 +7,7 @@ function getHeaderNumber(el) { export default { components: { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, }, data() { return { @@ -43,33 +42,40 @@ export default { } }, methods: { + close() { + this.$refs.disclosureDropdown?.close(); + }, generateHeaders() { + const BASE_PADDING = 16; const headers = [...this.blobViewer.querySelectorAll('h1,h2,h3,h4,h5,h6')]; - if (headers.length) { - const firstHeader = getHeaderNumber(headers[0]); - - this.items = headers.map((el) => ({ - text: el.textContent.trim(), - anchor: el.querySelector('a').getAttribute('id'), - spacing: Math.max((getHeaderNumber(el) - firstHeader) * 8, 0), - })); + if (headers.length === 0) { + return; } + + const firstHeader = getHeaderNumber(headers[0]); + + this.items = headers.map((el) => ({ + text: el.textContent.trim(), + href: `#${el.querySelector('a').getAttribute('id')}`, + extraAttrs: { + style: { + paddingLeft: `${BASE_PADDING + Math.max((getHeaderNumber(el) - firstHeader) * 8, 0)}px`, + }, + }, + })); }, }, }; </script> <template> - <gl-dropdown v-if="!isHidden && items.length" icon="list-bulleted" class="gl-mr-2" lazy> - <gl-dropdown-item v-for="(item, index) in items" :key="index" :href="`#${item.anchor}`"> - <span - :style="{ 'padding-left': `${item.spacing}px` }" - class="gl-display-block" - data-testid="tableContentsLink" - > - {{ item.text }} - </span> - </gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown + v-if="!isHidden && items.length" + ref="disclosureDropdown" + icon="list-bulleted" + class="gl-mr-2" + :items="items" + @action="close" + /> </template> diff --git a/app/assets/javascripts/blob/notebook/notebook_viewer.vue b/app/assets/javascripts/blob/notebook/notebook_viewer.vue index dc1a9cb865a..ade92f2562b 100644 --- a/app/assets/javascripts/blob/notebook/notebook_viewer.vue +++ b/app/assets/javascripts/blob/notebook/notebook_viewer.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import NotebookLab from '~/notebook/index.vue'; export default { @@ -51,7 +52,7 @@ export default { this.loading = false; }) .catch((e) => { - if (e.status !== 200) { + if (e.status !== HTTP_STATUS_OK) { this.loadError = true; } this.error = true; diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js index 2386508aef5..d81aa05c44e 100644 --- a/app/assets/javascripts/blob/openapi/index.js +++ b/app/assets/javascripts/blob/openapi/index.js @@ -1,10 +1,19 @@ import { setAttributes } from '~/lib/utils/dom_utils'; import axios from '~/lib/utils/axios_utils'; +import { getBaseURL, relativePathToAbsolute, joinPaths } from '~/lib/utils/url_utility'; + +const SANDBOX_FRAME_PATH = '/-/sandbox/swagger'; + +const getSandboxFrameSrc = () => { + const path = joinPaths(gon.relative_url_root || '', SANDBOX_FRAME_PATH); + return relativePathToAbsolute(path, getBaseURL()); +}; const createSandbox = () => { const iframeEl = document.createElement('iframe'); + setAttributes(iframeEl, { - src: '/-/sandbox/swagger', + src: getSandboxFrameSrc(), sandbox: 'allow-scripts allow-popups allow-forms', frameBorder: 0, width: '100%', diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 8062460f052..3a22b06c72e 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,7 +1,18 @@ import { sortBy, cloneDeep } from 'lodash'; -import { TYPE_BOARD, TYPE_ITERATION, TYPE_MILESTONE, TYPE_USER } from '~/graphql_shared/constants'; +import { + TYPENAME_BOARD, + TYPENAME_ITERATION, + TYPENAME_MILESTONE, + TYPENAME_USER, +} from '~/graphql_shared/constants'; import { isGid, convertToGraphQLId } from '~/graphql_shared/utils'; -import { ListType, MilestoneIDs, AssigneeFilterType, MilestoneFilterType } from './constants'; +import { + ListType, + MilestoneIDs, + AssigneeFilterType, + MilestoneFilterType, + boardQuery, +} from './constants'; export function getMilestone() { return null; @@ -40,9 +51,7 @@ export function formatListIssues(listIssues) { const boardItems = {}; const listData = listIssues.nodes.reduce((map, list) => { - let sortedIssues = list.issues.edges.map((issueNode) => ({ - ...issueNode.node, - })); + let sortedIssues = list.issues.nodes; if (list.listType !== ListType.closed) { sortedIssues = sortBy(sortedIssues, 'relativePosition'); } @@ -82,19 +91,19 @@ export function fullBoardId(boardId) { if (!boardId) { return null; } - return convertToGraphQLId(TYPE_BOARD, boardId); + return convertToGraphQLId(TYPENAME_BOARD, boardId); } export function fullIterationId(id) { - return convertToGraphQLId(TYPE_ITERATION, id); + return convertToGraphQLId(TYPENAME_ITERATION, id); } export function fullUserId(id) { - return convertToGraphQLId(TYPE_USER, id); + return convertToGraphQLId(TYPENAME_USER, id); } export function fullMilestoneId(id) { - return convertToGraphQLId(TYPE_MILESTONE, id); + return convertToGraphQLId(TYPENAME_MILESTONE, id); } export function fullLabelId(label) { @@ -305,6 +314,10 @@ export function transformBoardConfig() { return ''; } +export function getBoardQuery(boardType) { + return boardQuery[boardType].query; +} + export default { getMilestone, formatIssue, diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index 970e3509d20..d41fc1e9300 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -11,7 +11,12 @@ export default { BoardSettingsSidebar, BoardTopBar, }, - inject: ['fullBoardId'], + inject: ['initialBoardId'], + data() { + return { + boardId: this.initialBoardId, + }; + }, computed: { ...mapGetters(['isSidebarOpen']), }, @@ -21,13 +26,18 @@ export default { destroyed() { window.removeEventListener('popstate', refreshCurrentPage); }, + methods: { + switchBoard(id) { + this.boardId = id; + }, + }, }; </script> <template> <div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }"> - <board-top-bar /> - <board-content :board-id="fullBoardId" /> + <board-top-bar :board-id="boardId" @switchBoard="switchBoard" /> + <board-content :board-id="boardId" /> <board-settings-sidebar /> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 0c64cbad5b1..3071c1f334e 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -9,7 +9,7 @@ export default { BoardCardInner, }, mixins: [Tracking.mixin()], - inject: ['disabled'], + inject: ['disabled', 'isApolloBoard'], props: { list: { type: Object, @@ -63,6 +63,15 @@ export default { colorClass() { return this.isColorful ? 'gl-pl-4 gl-border-l-solid gl-border-4' : ''; }, + formattedItem() { + return this.isApolloBoard + ? { + ...this.item, + assignees: this.item.assignees?.nodes || [], + labels: this.item.labels?.nodes || [], + } + : this.item; + }, }, methods: { ...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']), @@ -106,7 +115,7 @@ export default { > <board-card-inner :list="list" - :item="item" + :item="formattedItem" :update-filters="true" :index="index" :show-work-item-type-icon="showWorkItemTypeIcon" diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 77df111afc1..88f51c71e06 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -214,7 +214,9 @@ export default { <template> <div> <div class="gl-display-flex" dir="auto"> - <h4 class="board-card-title gl-mb-0 gl-mt-0 gl-mr-3 gl-font-base gl-overflow-break-word"> + <h4 + class="board-card-title gl-min-w-0 gl-mb-0 gl-mt-0 gl-mr-3 gl-font-base gl-overflow-break-word" + > <issuable-blocked-icon v-if="item.blocked" :item="item" diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue index 706b453e868..f58f7838576 100644 --- a/app/assets/javascripts/boards/components/board_card_move_to_position.vue +++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue @@ -1,19 +1,17 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; -import { s__ } from '~/locale'; - import Tracking from '~/tracking'; +import { + BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS, + BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION, +} from '../constants'; export default { - i18n: { - moveToStartText: s__('Boards|Move to start of list'), - moveToEndText: s__('Boards|Move to end of list'), - }, + BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS, name: 'BoardCardMoveToPosition', components: { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, }, mixins: [Tracking.mixin()], props: { @@ -96,30 +94,30 @@ export default { allItemsLoadedInList: !this.listHasNextPage, }); }, + selectMoveAction({ text }) { + if (text === BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION) { + this.moveToStart(); + } else { + this.moveToEnd(); + } + }, }, }; </script> <template> - <gl-dropdown + <gl-disclosure-dropdown ref="dropdown" :key="itemIdentifier" - icon="ellipsis_v" - :text="s__('Boards|Move card')" - :text-sr-only="true" - class="move-to-position gl-display-block gl-mb-2 gl-ml-2 gl-mt-n3 gl-mr-n3" + class="move-to-position gl-display-block gl-mb-2 gl-ml-auto gl-mt-n3 gl-mr-n3 js-no-trigger" category="tertiary" + :items="$options.BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS" + icon="ellipsis_v" :tabindex="index" + :toggle-text="s__('Boards|Move card')" + :text-sr-only="true" no-caret - @keydown.esc.native="$emit('hide')" - > - <div> - <gl-dropdown-item @click.stop="moveToStart"> - {{ $options.i18n.moveToStartText }} - </gl-dropdown-item> - <gl-dropdown-item @click.stop="moveToEnd"> - {{ $options.i18n.moveToEndText }} - </gl-dropdown-item> - </div> - </gl-dropdown> + data-testid="board-move-to-position" + @action="selectMoveAction" + /> </template> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index b728b8dd22a..708e1539c6e 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -9,17 +9,17 @@ export default { BoardListHeader, BoardList, }, - inject: { - boardId: { - default: '', - }, - }, + inject: ['isApolloBoard'], props: { list: { type: Object, default: () => ({}), required: false, }, + boardId: { + type: String, + required: true, + }, }, computed: { ...mapState(['filterParams', 'highlightedLists']), @@ -28,7 +28,7 @@ export default { return this.highlightedLists.includes(this.list.id); }, listItems() { - return this.getBoardItemsByList(this.list.id); + return this.isApolloBoard ? [] : this.getBoardItemsByList(this.list.id); }, isListDraggable() { return isListDraggable(this.list); @@ -84,7 +84,13 @@ export default { :class="{ 'board-column-highlighted': highlighted }" > <board-list-header :list="list" /> - <board-list ref="board-list" :board-items="listItems" :list="list" /> + <board-list + ref="board-list" + :board-id="boardId" + :board-items="listItems" + :list="list" + :filter-params="filterParams" + /> </div> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 92f79e61f14..8a37719eae8 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -116,6 +116,8 @@ export default { value: this.boardListsToUse, delay: 100, delayOnTouchOnly: true, + filter: 'input', + preventOnFilter: false, }; return this.canDragColumns ? options : {}; @@ -172,6 +174,7 @@ export default { v-for="(list, index) in boardListsToUse" :key="index" ref="board" + :board-id="boardId" :list="list" :data-draggable-item-type="$options.draggableItemTypes.list" :class="{ 'gl-xs-display-none!': addColumnFormVisible }" diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index e6d1e558c37..6227f185eda 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -6,12 +6,13 @@ import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdow import { __, sprintf } from '~/locale'; import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; -import { BoardType, ISSUABLE, INCIDENT, issuableTypes } from '~/boards/constants'; +import { BoardType, ISSUABLE, INCIDENT } from '~/boards/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/issues/constants'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; -import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue'; +import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severity_widget.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; @@ -30,7 +31,7 @@ export default { SidebarSubscriptionsWidget, SidebarDropdownWidget, SidebarTodoWidget, - SidebarSeverity, + SidebarSeverityWidget, MountingPortal, SidebarHealthStatusWidget: () => import('ee_component/sidebar/components/health_status/sidebar_health_status_widget.vue'), @@ -66,7 +67,7 @@ export default { default: false, }, issuableType: { - default: issuableTypes.issue, + default: TYPE_ISSUE, }, isGroupBoard: { default: false, @@ -174,7 +175,7 @@ export default { /> </template> <template #default> - <board-sidebar-title /> + <board-sidebar-title data-testid="sidebar-title" /> <sidebar-assignees-widget :iid="activeBoardItem.iid" :full-path="fullPath" @@ -237,7 +238,7 @@ export default { > {{ __('None') }} </sidebar-labels-widget> - <sidebar-severity + <sidebar-severity-widget v-if="isIncidentSidebar" :iid="activeBoardItem.iid" :project-path="fullPath" diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index ce86a4d3123..1bc5d910561 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -327,7 +327,6 @@ export default { if (Array.isArray(value)) { return value.map((valueItem) => encodeURIComponent(valueItem)); } - return encodeURIComponent(value); } diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 060a708a22f..6f2b35f5191 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -8,7 +8,13 @@ import { sortableStart, sortableEnd } from '~/sortable/utils'; import Tracking from '~/tracking'; import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue'; -import { toggleFormEventPrefix, DraggableItemTypes } from '../constants'; +import { + DEFAULT_BOARD_LIST_ITEMS_SIZE, + toggleFormEventPrefix, + DraggableItemTypes, + listIssuablesQueries, + ListType, +} from 'ee_else_ce/boards/constants'; import eventHub from '../eventhub'; import BoardCard from './board_card.vue'; import BoardNewIssue from './board_new_issue.vue'; @@ -31,12 +37,24 @@ export default { BoardCardMoveToPosition, }, mixins: [Tracking.mixin()], - inject: ['isEpicBoard', 'disabled'], + inject: [ + 'isEpicBoard', + 'isGroupBoard', + 'disabled', + 'fullPath', + 'boardType', + 'issuableType', + 'isApolloBoard', + ], props: { list: { type: Object, required: true, }, + boardId: { + type: String, + required: true, + }, boardItems: { type: Array, required: true, @@ -48,6 +66,8 @@ export default { showCount: false, showIssueForm: false, showEpicForm: false, + currentList: null, + isLoadingMore: false, }; }, apollo: { @@ -66,15 +86,50 @@ export default { return this.isEpicBoard; }, }, + currentList: { + query() { + return listIssuablesQueries[this.issuableType].query; + }, + variables() { + return { + id: this.list.id, + ...this.listQueryVariables, + }; + }, + skip() { + return !this.isApolloBoard || this.list.collapsed; + }, + update(data) { + return data[this.boardType].board.lists.nodes[0]; + }, + context: { + isSingleRequest: true, + }, + }, }, computed: { ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams', 'isUpdateIssueOrderInProgress']), + boardListItems() { + return this.isApolloBoard + ? this.currentList?.[`${this.issuableType}s`].nodes || [] + : this.boardItems; + }, + listQueryVariables() { + return { + fullPath: this.fullPath, + boardId: this.boardId, + filters: this.filterParams, + isGroup: this.isGroupBoard, + isProject: !this.isGroupBoard, + first: DEFAULT_BOARD_LIST_ITEMS_SIZE, + }; + }, listItemsCount() { return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount; }, paginatedIssueText() { return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), { - pageSize: this.boardItems.length, + pageSize: this.boardListItems.length, total: this.listItemsCount, issuableType: this.isEpicBoard ? 'epics' : 'issues', }); @@ -86,13 +141,17 @@ export default { return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount; }, hasNextPage() { - return this.pageInfoByListId[this.list.id]?.hasNextPage; + return this.isApolloBoard + ? this.currentList?.[`${this.issuableType}s`].pageInfo?.hasNextPage + : this.pageInfoByListId[this.list.id]?.hasNextPage; }, loading() { - return this.listsFlags[this.list.id]?.isLoading; + return this.isApolloBoard + ? this.$apollo.queries.currentList.loading && !this.isLoadingMore + : this.listsFlags[this.list.id]?.isLoading; }, loadingMore() { - return this.listsFlags[this.list.id]?.isLoadingMore; + return this.isApolloBoard ? this.isLoadingMore : this.listsFlags[this.list.id]?.isLoadingMore; }, epicCreateFormVisible() { return this.isEpicBoard && this.list.listType !== 'closed' && this.showEpicForm; @@ -105,7 +164,7 @@ export default { return this.canMoveIssue ? this.$refs.list.$el : this.$refs.list; }, showingAllItems() { - return this.boardItems.length === this.listItemsCount; + return this.boardListItems.length === this.listItemsCount; }, showingAllItemsText() { return this.isEpicBoard @@ -128,7 +187,7 @@ export default { tag: 'ul', 'ghost-class': 'board-card-drag-active', 'data-list-id': this.list.id, - value: this.boardItems, + value: this.boardListItems, delay: 100, delayOnTouchOnly: true, }; @@ -138,9 +197,12 @@ export default { disableScrollingWhenMutationInProgress() { return this.hasNextPage && this.isUpdateIssueOrderInProgress; }, + showMoveToPosition() { + return !this.disabled && this.list.listType !== ListType.closed; + }, }, watch: { - boardItems() { + boardListItems() { this.$nextTick(() => { this.showCount = this.scrollHeight() > Math.ceil(this.listHeight()); }); @@ -165,10 +227,10 @@ export default { methods: { ...mapActions(['fetchItemsForList', 'moveItem']), listHeight() { - return this.listRef.getBoundingClientRect().height; + return this.listRef?.getBoundingClientRect()?.height || 0; }, scrollHeight() { - return this.listRef.scrollHeight; + return this.listRef?.scrollHeight || 0; }, scrollTop() { return this.listRef.scrollTop + this.listHeight(); @@ -176,8 +238,20 @@ export default { scrollToTop() { this.listRef.scrollTop = 0; }, - loadNextPage() { - this.fetchItemsForList({ listId: this.list.id, fetchNext: true }); + async loadNextPage() { + if (this.isApolloBoard) { + this.isLoadingMore = true; + await this.$apollo.queries.currentList.fetchMore({ + variables: { + ...this.listQueryVariables, + id: this.list.id, + after: this.currentList?.[`${this.issuableType}s`].pageInfo.endCursor, + }, + }); + this.isLoadingMore = false; + } else { + this.fetchItemsForList({ listId: this.list.id, fetchNext: true }); + } }, toggleForm() { if (this.isEpicBoard) { @@ -292,7 +366,7 @@ export default { :data-board="list.id" :data-board-type="list.listType" :class="{ - 'bg-danger-100': boardItemsSizeExceedsMax, + 'gl-bg-red-100 gl-rounded-bottom-left-base gl-rounded-bottom-right-base': boardItemsSizeExceedsMax, 'gl-overflow-hidden': disableScrollingWhenMutationInProgress, 'gl-overflow-y-auto': !disableScrollingWhenMutationInProgress, }" @@ -303,7 +377,7 @@ export default { @end="handleDragOnEnd" > <board-card - v-for="(item, index) in boardItems" + v-for="(item, index) in boardListItems" ref="issue" :key="item.id" :index="index" @@ -312,13 +386,12 @@ export default { :data-draggable-item-type="$options.draggableItemTypes.card" :show-work-item-type-icon="!isEpicBoard" > - <!-- TODO: remove the condition when https://gitlab.com/gitlab-org/gitlab/-/issues/377862 is resolved --> <board-card-move-to-position - v-if="!isEpicBoard && !disabled" + v-if="showMoveToPosition" :item="item" :index="index" :list="list" - :list-items-length="boardItems.length" + :list-items-length="boardListItems.length" /> </board-card> <gl-intersection-observer @appear="onReachingListBottom"> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 14dff8de70f..749fae0c426 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -125,7 +125,7 @@ export default { return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse; }, chevronIcon() { - return this.list.collapsed ? 'chevron-right' : 'chevron-down'; + return this.list.collapsed ? 'chevron-lg-right' : 'chevron-lg-down'; }, isNewIssueShown() { return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard; @@ -135,7 +135,9 @@ export default { }, isSettingsShown() { return ( - this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed + this.listType !== ListType.backlog && + this.listType !== ListType.closed && + !this.list.collapsed ); }, uniqueKey() { @@ -321,6 +323,7 @@ export default { v-if="listType !== 'label'" v-gl-tooltip.hover :class="{ + 'gl-text-gray-500': list.collapsed, 'gl-display-block': list.collapsed || listType === 'milestone', }" :title="listTitle" @@ -376,7 +379,7 @@ export default { <!-- EE end --> <div - class="issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-secondary" + class="gl-font-sm issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-secondary" data-testid="issue-count-badge" :class="{ 'gl-display-none!': list.collapsed && isSwimlanesHeader, @@ -386,7 +389,7 @@ export default { <span class="gl-display-inline-flex" :class="{ 'gl-rotate-90': list.collapsed }"> <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" :size="16" /> + <gl-icon class="gl-mr-2" :name="countIcon" :size="14" /> <item-count v-if="!isLoading" :items-size="isEpicBoard ? list.epicsCount : boardList.issuesCount" @@ -397,7 +400,7 @@ export default { <template v-if="canShowTotalWeight"> <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3" data-testid="weight"> - <gl-icon class="gl-mr-2" name="weight" /> + <gl-icon class="gl-mr-2" name="weight" :size="14" /> {{ totalWeight }} </span> </template> @@ -413,6 +416,7 @@ export default { :aria-label="$options.i18n.newIssue" :title="$options.i18n.newIssue" class="no-drag" + size="small" icon="plus" @click="showNewIssueForm" /> @@ -424,6 +428,7 @@ export default { :aria-label="$options.i18n.newEpic" :title="$options.i18n.newEpic" class="no-drag" + size="small" icon="plus" @click="showNewEpicForm" /> @@ -434,6 +439,7 @@ export default { v-gl-tooltip.hover :aria-label="$options.i18n.listSettings" class="no-drag" + size="small" :title="$options.i18n.listSettings" icon="settings" @click="openSidebarSettings" diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue index 368feba9a44..2e20ed70bb0 100644 --- a/app/assets/javascripts/boards/components/board_top_bar.vue +++ b/app/assets/javascripts/boards/components/board_top_bar.vue @@ -2,6 +2,7 @@ import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue'; import IssueBoardFilteredSearch from 'ee_else_ce/boards/components/issue_board_filtered_search.vue'; +import { getBoardQuery } from 'ee_else_ce/boards/boards_util'; import ConfigToggle from './config_toggle.vue'; import NewBoardButton from './new_board_button.vue'; import ToggleFocus from './toggle_focus.vue'; @@ -19,7 +20,46 @@ export default { EpicBoardFilteredSearch: () => import('ee_component/boards/components/epic_filtered_search.vue'), }, - inject: ['swimlanesFeatureAvailable', 'canAdminList', 'isSignedIn', 'isIssueBoard'], + inject: [ + 'swimlanesFeatureAvailable', + 'canAdminList', + 'isSignedIn', + 'isIssueBoard', + 'fullPath', + 'boardType', + 'isEpicBoard', + 'isApolloBoard', + ], + props: { + boardId: { + type: String, + required: true, + }, + }, + data() { + return { + board: {}, + }; + }, + apollo: { + board: { + query() { + return getBoardQuery(this.boardType, this.isEpicBoard); + }, + variables() { + return { + fullPath: this.fullPath, + boardId: this.boardId, + }; + }, + skip() { + return !this.isApolloBoard; + }, + update(data) { + return data.workspace.board; + }, + }, + }, }; </script> @@ -31,7 +71,7 @@ export default { <div 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 /> + <boards-selector :board-apollo="board" @switchBoard="$emit('switchBoard', $event)" /> <new-board-button /> <issue-board-filtered-search v-if="isIssueBoard" /> <epic-board-filtered-search v-else /> diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index d26aeb69dd5..a1a49386b37 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -51,6 +51,7 @@ export default { 'weights', 'boardType', 'isGroupBoard', + 'isApolloBoard', ], props: { throttleDuration: { @@ -58,15 +59,20 @@ export default { default: 200, required: false, }, + boardApollo: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { return { hasScrollFade: false, - loadingBoards: 0, - loadingRecentBoards: false, scrollFadeInitialized: false, boards: [], recentBoards: [], + loadingBoards: false, + loadingRecentBoards: false, throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration), contentClientHeight: 0, maxPosition: 0, @@ -77,11 +83,14 @@ export default { computed: { ...mapState(['board', 'isBoardLoading']), + boardToUse() { + return this.isApolloBoard ? this.boardApollo : this.board; + }, parentType() { return this.boardType; }, loading() { - return this.loadingRecentBoards || Boolean(this.loadingBoards); + return this.loadingRecentBoards || this.loadingBoards; }, filteredBoards() { return this.boards.filter((board) => @@ -94,6 +103,9 @@ export default { showDelete() { return this.boards.length > 1; }, + showDropdown() { + return this.showCreate || this.hasMissingBoards; + }, scrollFadeClass() { return { 'fade-out': !this.hasScrollFade, @@ -116,7 +128,7 @@ export default { this.scrollFadeInitialized = false; this.$nextTick(this.setScrollFade); }, - board(newBoard) { + boardToUse(newBoard) { document.title = newBoard.name; }, }, @@ -159,8 +171,10 @@ export default { return { fullPath: this.fullPath }; }, query: this.boardQuery, - loadingKey: 'loadingBoards', update: (data) => this.boardUpdate(data, 'boards'), + watchLoading: (isLoading) => { + this.loadingBoards = isLoading; + }, }); this.loadRecentBoards(); @@ -171,8 +185,10 @@ export default { return { fullPath: this.fullPath }; }, query: this.recentBoardsQuery, - loadingKey: 'loadingRecentBoards', update: (data) => this.boardUpdate(data, 'recentIssueBoards'), + watchLoading: (isLoading) => { + this.loadingRecentBoards = isLoading; + }, }); }, isScrolledUp() { @@ -210,9 +226,14 @@ export default { boardType: this.boardType, }); }, + fullBoardId(boardId) { + return fullBoardId(boardId); + }, async switchBoard(boardId, e) { if (isMetaKey(e)) { window.open(`${this.boardBaseUrl}/${boardId}`, '_blank'); + } else if (this.isApolloBoard) { + this.$emit('switchBoard', this.fullBoardId(boardId)); } else { this.unsetActiveId(); this.fetchCurrentBoard(boardId); @@ -230,12 +251,13 @@ export default { <div class="boards-switcher gl-mr-3" data-testid="boards-selector"> <span class="boards-selector-wrapper"> <gl-dropdown + v-if="showDropdown" data-testid="boards-dropdown" data-qa-selector="boards_dropdown" toggle-class="dropdown-menu-toggle" menu-class="flex-column dropdown-extended-height" :loading="isBoardLoading" - :text="board.name" + :text="boardToUse.name" @show="loadBoards" > <p class="gl-dropdown-header-top" @mousedown.prevent> @@ -333,7 +355,7 @@ export default { :can-admin-board="canAdminBoard" :scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled" :weights="weights" - :current-board="board" + :current-board="boardToUse" :current-page="currentPage" @cancel="cancel" /> diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index 38a171e8889..7749391ec6f 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -7,7 +7,7 @@ import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_sea import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import issueBoardFilters from '~/boards/issue_board_filters'; -import { TYPE_USER } from '~/graphql_shared/constants'; +import { TYPENAME_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { @@ -181,7 +181,7 @@ export default { return gon?.current_user_id ? [ { - id: convertToGraphQLId(TYPE_USER, gon.current_user_id), + id: convertToGraphQLId(TYPENAME_USER, gon.current_user_id), name: gon.current_user_fullname, username: gon.current_username, avatarUrl: gon.current_user_avatar_url, diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index b09b1d48ca5..c3f7c7d3ca2 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -102,7 +102,7 @@ export default { <gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement"> <span class="bold">{{ __('Due date') }}</span> <br /> - <span :class="{ 'text-danger-muted': isPastDue }">{{ title }}</span> + <span :class="{ 'gl-text-red-300': isPastDue }">{{ title }}</span> </gl-tooltip> </span> </template> 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 53e574e9942..43a2b13b81c 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import { joinPaths } from '~/lib/utils/url_utility'; @@ -13,6 +13,7 @@ export default { GlButton, GlFormGroup, GlFormInput, + GlLink, BoardEditableItem, }, directives: { @@ -130,7 +131,11 @@ export default { @off-click="handleOffClick" > <template #title> - <span data-testid="item-title">{{ item.title }}</span> + <span data-testid="item-title"> + <gl-link class="gl-reset-color gl-hover-text-blue-800" :href="item.webUrl"> + {{ item.title }} + </gl-link> + </span> </template> <template #collapsed> <span class="gl-text-gray-800">{{ item.referencePath }}</span> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 91b7f5004ad..712e3e1ac4a 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -1,5 +1,6 @@ import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; -import { __ } from '~/locale'; +import { TYPE_ISSUE } from '~/issues/constants'; +import { s__, __ } from '~/locale'; import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql'; @@ -7,6 +8,9 @@ import updateBoardListMutation from './graphql/board_list_update.mutation.graphq import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql'; import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql'; +import groupBoardQuery from './graphql/group_board.query.graphql'; +import projectBoardQuery from './graphql/project_board.query.graphql'; +import listIssuesQuery from './graphql/lists_issues.query.graphql'; /* eslint-disable-next-line @gitlab/require-i18n-strings */ export const AssigneeIdParamValues = ['Any', 'None']; @@ -59,26 +63,35 @@ export const INCIDENT = 'INCIDENT'; export const flashAnimationDuration = 2000; +export const boardQuery = { + [BoardType.group]: { + query: groupBoardQuery, + }, + [BoardType.project]: { + query: projectBoardQuery, + }, +}; + export const listsQuery = { - [issuableTypes.issue]: { + [TYPE_ISSUE]: { query: boardListsQuery, }, }; export const updateListQueries = { - [issuableTypes.issue]: { + [TYPE_ISSUE]: { mutation: updateBoardListMutation, }, }; export const deleteListQueries = { - [issuableTypes.issue]: { + [TYPE_ISSUE]: { mutation: destroyBoardListMutation, }, }; export const titleQueries = { - [issuableTypes.issue]: { + [TYPE_ISSUE]: { mutation: issueSetTitleMutation, }, [issuableTypes.epic]: { @@ -87,7 +100,7 @@ export const titleQueries = { }; export const subscriptionQueries = { - [issuableTypes.issue]: { + [TYPE_ISSUE]: { mutation: issueSetSubscriptionMutation, }, [issuableTypes.epic]: { @@ -95,8 +108,14 @@ export const subscriptionQueries = { }, }; +export const listIssuablesQueries = { + [TYPE_ISSUE]: { + query: listIssuesQuery, + }, +}; + export const FilterFields = { - [issuableTypes.issue]: [ + [TYPE_ISSUE]: [ 'assigneeUsername', 'assigneeWildcardId', 'authorUsername', @@ -141,3 +160,21 @@ export default { }; export const DEFAULT_BOARD_LIST_ITEMS_SIZE = 10; + +export const BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION = s__('Boards|Move to start of list'); +export const BOARD_CARD_MOVE_TO_POSITIONS_END_OPTION = s__('Boards|Move to end of list'); + +/** + * Actions are stubbed in order to pass validation + * for GlDisclosureDropdown items property + */ +export const BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS = [ + { + text: BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION, + action: () => {}, + }, + { + text: BOARD_CARD_MOVE_TO_POSITIONS_END_OPTION, + action: () => {}, + }, +]; diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql index ae6394f9a2f..0b9e416d408 100644 --- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql @@ -19,10 +19,8 @@ query BoardListsEE( id listType issues(first: $first, filters: $filters, after: $after) { - edges { - node { - ...Issue - } + nodes { + ...Issue } pageInfo { endCursor @@ -42,10 +40,8 @@ query BoardListsEE( id listType issues(first: $first, filters: $filters, after: $after) { - edges { - node { - ...Issue - } + nodes { + ...Issue } pageInfo { endCursor diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 968832a092d..4c6f341828c 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -3,8 +3,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import BoardApp from '~/boards/components/board_app.vue'; import '~/boards/filters/due_date_filters'; -import { BoardType, issuableTypes } from '~/boards/constants'; +import { BoardType } from '~/boards/constants'; import store from '~/boards/stores'; +import { TYPE_ISSUE } from '~/issues/constants'; import { NavigationType, isLoggedIn, @@ -24,6 +25,7 @@ const apolloProvider = new VueApollo({ function mountBoardApp(el) { const { boardId, groupId, fullPath, rootPath } = el.dataset; + const isApolloBoard = window.gon?.features?.apolloBoards; const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); @@ -33,20 +35,22 @@ function mountBoardApp(el) { const boardType = el.dataset.parent; - store.dispatch('fetchBoard', { - fullPath, - fullBoardId: fullBoardId(boardId), - boardType, - }); + if (!isApolloBoard) { + store.dispatch('fetchBoard', { + fullPath, + fullBoardId: fullBoardId(boardId), + boardType, + }); - store.dispatch('setInitialBoardData', { - boardId, - fullBoardId: fullBoardId(boardId), - fullPath, - boardType, - disabled: parseBoolean(el.dataset.disabled) || true, - issuableType: issuableTypes.issue, - }); + store.dispatch('setInitialBoardData', { + boardId, + fullBoardId: fullBoardId(boardId), + fullPath, + boardType, + disabled: parseBoolean(el.dataset.disabled) || true, + issuableType: TYPE_ISSUE, + }); + } // eslint-disable-next-line no-new new Vue({ @@ -55,8 +59,8 @@ function mountBoardApp(el) { store, apolloProvider, provide: { - isApolloBoard: window.gon?.features?.apolloBoards, - fullBoardId: fullBoardId(boardId), + isApolloBoard, + initialBoardId: fullBoardId(boardId), disabled: parseBoolean(el.dataset.disabled), groupId: Number(groupId), rootPath, @@ -72,7 +76,7 @@ function mountBoardApp(el) { labelsFilterBasePath: el.dataset.labelsFilterBasePath, releasesFetchPath: el.dataset.releasesFetchPath, timeTrackingLimitToHours: parseBoolean(el.dataset.timeTrackingLimitToHours), - issuableType: issuableTypes.issue, + issuableType: TYPE_ISSUE, emailsDisabled: parseBoolean(el.dataset.emailsDisabled), hasMissingBoards: parseBoolean(el.dataset.hasMissingBoards), weights: el.dataset.weights ? JSON.parse(el.dataset.weights) : [], diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 07b127d86e2..1b4e6334723 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -11,7 +11,6 @@ import { deleteListQueries, listsQuery, updateListQueries, - issuableTypes, FilterFields, ListTypeTitles, DraggableItemTypes, @@ -35,6 +34,7 @@ import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_defe import { fetchPolicies } from '~/lib/graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client'; +import { TYPE_ISSUE } from '~/issues/constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { queryToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -138,7 +138,7 @@ export default { fullPath, boardId: fullBoardId, filters: filterParams, - ...(issuableType === issuableTypes.issue && { + ...(issuableType === TYPE_ISSUE && { isGroup: boardType === BoardType.group, isProject: boardType === BoardType.project, }), diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index 9e746f1a1b8..0ad71165996 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -1,5 +1,6 @@ import { find } from 'lodash'; -import { inactiveId, issuableTypes } from '../constants'; +import { TYPE_ISSUE } from '~/issues/constants'; +import { inactiveId } from '../constants'; export default { isSidebarOpen: (state) => state.activeId !== inactiveId, @@ -43,7 +44,7 @@ export default { }, isIssueBoard: (state) => { - return state.issuableType === issuableTypes.issue; + return state.issuableType === TYPE_ISSUE; }, isEpicBoard: () => { diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 44abb2030c7..fef5862f319 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -15,9 +15,11 @@ const updateListItemsCount = ({ state, listId, value }) => { } }; -export const removeItemFromList = ({ state, listId, itemId }) => { +export const removeItemFromList = ({ state, listId, itemId, reordering = false }) => { Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], itemId)); - updateListItemsCount({ state, listId, value: -1 }); + if (!reordering) { + updateListItemsCount({ state, listId, value: -1 }); + } }; export const addItemToList = ({ @@ -28,6 +30,7 @@ export const addItemToList = ({ moveAfterId, atIndex, positionInList, + reordering = false, }) => { const listIssues = state.boardItemsByListId[listId]; let newIndex = atIndex || 0; @@ -41,7 +44,9 @@ export const addItemToList = ({ } listIssues.splice(newIndex, 0, itemId); Vue.set(state.boardItemsByListId, listId, listIssues); - updateListItemsCount({ state, listId, value: moveToStartOrLast ? 0 : 1 }); + if (!reordering) { + updateListItemsCount({ state, listId, value: moveToStartOrLast ? 0 : 1 }); + } }; export default { diff --git a/app/assets/javascripts/branches/branch_sort_dropdown.js b/app/assets/javascripts/branches/branch_sort_dropdown.js index 9914ce05a95..9ea1331d563 100644 --- a/app/assets/javascripts/branches/branch_sort_dropdown.js +++ b/app/assets/javascripts/branches/branch_sort_dropdown.js @@ -1,8 +1,9 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import SortDropdown from './components/sort_dropdown.vue'; const mountDropdownApp = (el) => { - const { mode, projectBranchesFilteredPath, sortOptions } = el.dataset; + const { projectBranchesFilteredPath, sortOptions, showDropdown, sortedBy } = el.dataset; return new Vue({ el, @@ -11,9 +12,10 @@ const mountDropdownApp = (el) => { SortDropdown, }, provide: { - mode, projectBranchesFilteredPath, sortOptions: JSON.parse(sortOptions), + showDropdown: parseBoolean(showDropdown), + sortedBy, }, render: (createElement) => createElement(SortDropdown), }); diff --git a/app/assets/javascripts/branches/components/sort_dropdown.vue b/app/assets/javascripts/branches/components/sort_dropdown.vue index 263efcaa788..99c82fc9a5a 100644 --- a/app/assets/javascripts/branches/components/sort_dropdown.vue +++ b/app/assets/javascripts/branches/components/sort_dropdown.vue @@ -1,10 +1,8 @@ <script> import { GlCollapsibleListbox, GlSearchBoxByClick } from '@gitlab/ui'; -import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility'; +import { mergeUrlParams, visitUrl } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; -const OVERVIEW_MODE = 'overview'; - export default { i18n: { searchPlaceholder: s__('Branches|Filter by branch name'), @@ -13,17 +11,20 @@ export default { GlCollapsibleListbox, GlSearchBoxByClick, }, - inject: ['projectBranchesFilteredPath', 'sortOptions', 'mode'], + // external parameters + inject: [ + 'projectBranchesFilteredPath', + 'sortOptions', // dropdown choices (value, text) pairs + 'showDropdown', // if not set, only text filter is shown + 'sortedBy', // (required) value of choice to sort by + ], + // own attributes, also in created() data() { return { - selectedKey: 'updated_desc', searchTerm: '', }; }, computed: { - shouldShowDropdown() { - return this.mode !== OVERVIEW_MODE; - }, selectedSortMethodName() { return this.sortOptions[this.selectedKey]; }, @@ -31,26 +32,16 @@ export default { return Object.entries(this.sortOptions).map(([value, text]) => ({ value, text })); }, }, + // contructor or initialization function created() { - const sortValue = getParameterValues('sort'); - const searchValue = getParameterValues('search'); - - if (sortValue.length > 0) { - [this.selectedKey] = sortValue; - } - - if (searchValue.length > 0) { - [this.searchTerm] = searchValue; - } + this.selectedKey = this.sortedBy; }, methods: { visitUrlFromOption(sortKey) { this.selectedKey = sortKey; const urlParams = {}; - if (this.mode !== OVERVIEW_MODE) { - urlParams.sort = sortKey; - } + urlParams.sort = sortKey; urlParams.search = this.searchTerm.length > 0 ? this.searchTerm : null; @@ -71,7 +62,7 @@ export default { /> <gl-collapsible-listbox - v-if="shouldShowDropdown" + v-if="showDropdown" v-model="selectedKey" :items="listboxItems" :toggle-text="selectedSortMethodName" diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue index 4466a6a8081..9c79adffdae 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue @@ -1,12 +1,8 @@ <script> +import { TYPENAME_GROUP } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { - ADD_MUTATION_ACTION, - DELETE_MUTATION_ACTION, - GRAPHQL_GROUP_TYPE, - UPDATE_MUTATION_ACTION, -} from '../constants'; +import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants'; import getGroupVariables from '../graphql/queries/group_variables.query.graphql'; import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql'; import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql'; @@ -24,7 +20,7 @@ export default { return this.glFeatures.groupScopedCiVariables; }, graphqlId() { - return convertToGraphQLId(GRAPHQL_GROUP_TYPE, this.groupId); + return convertToGraphQLId(TYPENAME_GROUP, this.groupId); }, }, mutationData: { diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue index 6326940148a..43938e9b88f 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue @@ -1,12 +1,8 @@ <script> +import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { - ADD_MUTATION_ACTION, - DELETE_MUTATION_ACTION, - GRAPHQL_PROJECT_TYPE, - UPDATE_MUTATION_ACTION, -} from '../constants'; +import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants'; import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql'; import getProjectVariables from '../graphql/queries/project_variables.query.graphql'; import addProjectVariable from '../graphql/mutations/project_add_variable.mutation.graphql'; @@ -22,7 +18,7 @@ export default { inject: ['projectFullPath', 'projectId'], computed: { graphqlId() { - return convertToGraphQLId(GRAPHQL_PROJECT_TYPE, this.projectId); + return convertToGraphQLId(TYPENAME_PROJECT, this.projectId); }, }, mutationData: { diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue index 967125c7b0a..16034cce381 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue @@ -14,9 +14,11 @@ import { GlModal, GlSprintf, } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { getCookie, setCookie } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import Tracking from '~/tracking'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { allEnvironments, @@ -31,6 +33,7 @@ import { EVENT_ACTION, EXPANDED_VARIABLES_NOTE, EDIT_VARIABLE_ACTION, + FLAG_LINK_TITLE, VARIABLE_ACTIONS, variableOptions, } from '../constants'; @@ -41,13 +44,6 @@ import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; const trackingMixin = Tracking.mixin({ label: EVENT_LABEL }); export default { - modalId: ADD_CI_VARIABLE_MODAL_ID, - tokens: awsTokens, - tokenList: awsTokenList, - awsTipMessage: AWS_TIP_MESSAGE, - containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, - environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, - expandedVariablesNote: EXPANDED_VARIABLES_NOTE, components: { CiEnvironmentsDropdown, GlAlert, @@ -64,7 +60,7 @@ export default { GlModal, GlSprintf, }, - mixins: [trackingMixin], + mixins: [glFeatureFlagsMixin(), trackingMixin], inject: [ 'awsLogoSvgPath', 'awsTipCommandsLink', @@ -74,8 +70,8 @@ export default { 'environmentScopeLink', 'isProtectedByDefault', 'maskedEnvironmentVariablesLink', + 'maskableRawRegex', 'maskableRegex', - 'protectedEnvironmentVariablesLink', ], props: { areScopedVariablesAvailable: { @@ -121,7 +117,7 @@ export default { }, computed: { canMask() { - const regex = RegExp(this.maskableRegex); + const regex = RegExp(this.useRawMaskableRegexp ? this.maskableRawRegex : this.maskableRegex); return regex.test(this.variable.value); }, canSubmit() { @@ -138,7 +134,10 @@ export default { return this.mode === EDIT_VARIABLE_ACTION; }, isExpanded() { - return !this.variable.raw; + return !this.isRaw; + }, + isRaw() { + return this.variable.raw; }, isTipVisible() { return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); @@ -174,6 +173,9 @@ export default { return true; }, + useRawMaskableRegexp() { + return this.isRaw; + }, variableValidationFeedback() { return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; }, @@ -273,7 +275,20 @@ export default { this.validationErrorEventProperty = ''; }, }, - defaultScope: allEnvironments.text, + i18n: { + awsTipMessage: AWS_TIP_MESSAGE, + containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, + defaultScope: allEnvironments.text, + environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, + expandedVariablesNote: EXPANDED_VARIABLES_NOTE, + flagsLinkTitle: FLAG_LINK_TITLE, + }, + flagLink: helpPagePath('ci/variables/index', { + anchor: 'define-a-cicd-variable-in-the-ui', + }), + modalId: ADD_CI_VARIABLE_MODAL_ID, + tokens: awsTokens, + tokenList: awsTokenList, variableOptions, }; </script> @@ -315,11 +330,7 @@ export default { class="gl-font-monospace!" spellcheck="false" /> - <p - v-if="variable.raw" - class="gl-mt-2 gl-mb-0 text-secondary" - data-testid="raw-variable-tip" - > + <p v-if="isRaw" class="gl-mt-2 gl-mb-0 text-secondary" data-testid="raw-variable-tip"> {{ __('Variable value will be evaluated as raw string.') }} </p> </gl-form-group> @@ -340,15 +351,20 @@ export default { 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> + <div class="gl-display-flex gl-align-items-center"> + <span class="gl-mr-2"> + {{ __('Environment scope') }} + </span> + <gl-link + class="gl-display-flex" + :title="$options.i18n.environmentScopeLinkTitle" + :href="environmentScopeLink" + target="_blank" + data-testid="environment-scope-link" + > + <gl-icon name="question-o" :size="14" /> + </gl-link> + </div> </template> <ci-environments-dropdown v-if="areScopedVariablesAvailable" @@ -358,12 +374,27 @@ export default { @create-environment-scope="createEnvironmentScope" /> - <gl-form-input v-else :value="$options.defaultScope" class="gl-w-full" readonly /> + <gl-form-input v-else :value="$options.i18n.defaultScope" class="gl-w-full" readonly /> </gl-form-group> </template> </div> - <gl-form-group :label="__('Flags')" label-for="ci-variable-flags"> + <gl-form-group> + <template #label> + <div class="gl-display-flex gl-align-items-center"> + <span class="gl-mr-2"> + {{ __('Flags') }} + </span> + <gl-link + class="gl-display-flex" + :title="$options.i18n.flagsLinkTitle" + :href="$options.flagLink" + target="_blank" + > + <gl-icon name="question-o" :size="14" /> + </gl-link> + </div> + </template> <gl-form-checkbox v-model="variable.protected" class="gl-mb-0" @@ -371,9 +402,6 @@ export default { :data-is-protected-checked="variable.protected" > {{ __('Protect variable') }} - <gl-link target="_blank" :href="protectedEnvironmentVariablesLink"> - <gl-icon name="question" :size="12" /> - </gl-link> <p class="gl-mt-2 text-secondary"> {{ __('Export variable to pipelines running on protected branches and tags only.') }} </p> @@ -384,9 +412,6 @@ export default { data-testid="ci-variable-masked-checkbox" > {{ __('Mask variable') }} - <gl-link target="_blank" :href="maskedEnvironmentVariablesLink"> - <gl-icon name="question" :size="12" /> - </gl-link> <p class="gl-mt-2 text-secondary"> {{ __('Variable will be masked in job logs.') }} <span @@ -397,7 +422,7 @@ export default { {{ __('Requires values to meet regular expression requirements.') }}</span > <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{ - __('More information') + __('Learn more.') }}</gl-link> </p> </gl-form-checkbox> @@ -408,11 +433,8 @@ export default { @change="setVariableRaw" > {{ __('Expand variable reference') }} - <gl-link target="_blank" :href="containsVariableReferenceLink"> - <gl-icon name="question" :size="12" /> - </gl-link> <p class="gl-mt-2 gl-mb-0 gl-text-secondary"> - <gl-sprintf :message="$options.expandedVariablesNote"> + <gl-sprintf :message="$options.i18n.expandedVariablesNote"> <template #code="{ content }"> <code>{{ content }}</code> </template> @@ -428,10 +450,10 @@ export default { data-testid="aws-guidance-tip" @dismiss="dismissTip" > - <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap-wrap gl-md-flex-wrap-nowrap"> + <div class="gl-display-flex gl-flex-direction-row gl-md-flex-wrap-nowraps gl-gap-3"> <div> <p> - <gl-sprintf :message="$options.awsTipMessage"> + <gl-sprintf :message="$options.i18n.awsTipMessage"> <template #deployLink="{ content }"> <gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link> </template> @@ -467,7 +489,7 @@ export default { variant="warning" data-testid="contains-variable-reference" > - <gl-sprintf :message="$options.containsVariableReferenceMessage"> + <gl-sprintf :message="$options.i18n.containsVariableReferenceMessage"> <template #code="{ content }"> <code>{{ content }}</code> </template> diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js index 828d0724d93..627ace1b28e 100644 --- a/app/assets/javascripts/ci/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js @@ -72,14 +72,14 @@ export const AWS_TOKEN_CONSTANTS = [AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_S export const CONTAINS_VARIABLE_REFERENCE_MESSAGE = __( 'Unselect "Expand variable reference" if you want to use the variable value as a raw string.', ); - +export const DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT = s__( + 'CiVariables|You have reached the maximum number of variables available. To add new variables, you must reduce the number of defined variables.', +); export const ENVIRONMENT_SCOPE_LINK_TITLE = __('Learn more'); export const EXCEEDS_VARIABLE_LIMIT_TEXT = s__( 'CiVariables|This %{entity} has %{currentVariableCount} defined CI/CD variables. The maximum number of variables per %{entity} is %{maxVariableLimit}. To add new variables, you must reduce the number of defined variables.', ); -export const DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT = s__( - 'CiVariables|You have reached the maximum number of variables available. To add new variables, you must reduce the number of defined variables.', -); +export const FLAG_LINK_TITLE = s__('CiVariable|Define a CI/CD variable in the UI'); export const MAXIMUM_VARIABLE_LIMIT_REACHED = s__( 'CiVariables|Maximum number of variables reached.', ); @@ -88,9 +88,6 @@ export const ADD_VARIABLE_ACTION = 'ADD_VARIABLE'; export const EDIT_VARIABLE_ACTION = 'EDIT_VARIABLE'; export const VARIABLE_ACTIONS = [ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION]; -export const GRAPHQL_PROJECT_TYPE = 'Project'; -export const GRAPHQL_GROUP_TYPE = 'Group'; - export const ADD_MUTATION_ACTION = 'add'; export const UPDATE_MUTATION_ACTION = 'update'; export const DELETE_MUTATION_ACTION = 'delete'; diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js index 10203383ba0..cafe3df35d0 100644 --- a/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js @@ -3,14 +3,9 @@ import { convertObjectPropsToCamelCase, convertObjectPropsToSnakeCase, } from '~/lib/utils/common_utils'; +import { TYPENAME_CI_VARIABLE, TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { - GRAPHQL_GROUP_TYPE, - GRAPHQL_PROJECT_TYPE, - groupString, - instanceString, - projectString, -} from '../constants'; +import { groupString, instanceString, projectString } from '../constants'; import getProjectVariables from './queries/project_variables.query.graphql'; import getGroupVariables from './queries/group_variables.query.graphql'; import getAdminVariables from './queries/variables.query.graphql'; @@ -30,7 +25,7 @@ const mapVariableTypes = (variables = [], kind) => { return { __typename: `Ci${kind}Variable`, ...convertObjectPropsToCamelCase(ciVar), - id: convertToGraphQLId('Ci::Variable', ciVar.id), + id: convertToGraphQLId(TYPENAME_CI_VARIABLE, ciVar.id), variableType: ciVar.variable_type ? ciVar.variable_type.toUpperCase() : ciVar.variableType, }; }); @@ -40,10 +35,10 @@ const prepareProjectGraphQLResponse = ({ data, id, errors = [] }) => { return { errors, project: { - __typename: GRAPHQL_PROJECT_TYPE, - id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, id), + __typename: TYPENAME_PROJECT, + id: convertToGraphQLId(TYPENAME_PROJECT, id), ciVariables: { - __typename: `Ci${GRAPHQL_PROJECT_TYPE}VariableConnection`, + __typename: 'CiProjectVariableConnection', pageInfo: { __typename: 'PageInfo', hasNextPage: false, @@ -61,10 +56,10 @@ const prepareGroupGraphQLResponse = ({ data, id, errors = [] }) => { return { errors, group: { - __typename: GRAPHQL_GROUP_TYPE, - id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, id), + __typename: TYPENAME_GROUP, + id: convertToGraphQLId(TYPENAME_GROUP, id), ciVariables: { - __typename: `Ci${GRAPHQL_GROUP_TYPE}VariableConnection`, + __typename: `CiGroupVariableConnection`, pageInfo: { __typename: 'PageInfo', hasNextPage: false, diff --git a/app/assets/javascripts/ci/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js index 174a59aba42..4270c3c67fc 100644 --- a/app/assets/javascripts/ci/ci_variable_list/index.js +++ b/app/assets/javascripts/ci/ci_variable_list/index.js @@ -21,11 +21,11 @@ const mountCiVariableListApp = (containerEl) => { isGroup, isProject, maskedEnvironmentVariablesLink, + maskableRawRegex, maskableRegex, projectFullPath, projectId, protectedByDefault, - protectedEnvironmentVariablesLink, } = containerEl.dataset; const parsedIsProject = parseBoolean(isProject); @@ -63,10 +63,10 @@ const mountCiVariableListApp = (containerEl) => { isProject: parsedIsProject, isProtectedByDefault, maskedEnvironmentVariablesLink, + maskableRawRegex, maskableRegex, projectFullPath, projectId, - protectedEnvironmentVariablesLink, }, render(createElement) { return createElement(component); diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index 375db7f3054..ea7201efcd9 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -1,6 +1,8 @@ <script> import { GlDrawer } from '@gitlab/ui'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import { __ } from '~/locale'; +import { DRAWER_CONTAINER_CLASS } from '../job_assistant_drawer/constants'; import FirstPipelineCard from './cards/first_pipeline_card.vue'; import GettingStartedCard from './cards/getting_started_card.vue'; import PipelineConfigReferenceCard from './cards/pipeline_config_reference_card.vue'; @@ -26,14 +28,15 @@ export default { required: false, default: false, }, + zIndex: { + type: Number, + required: false, + default: 200, + }, }, computed: { - drawerCardStyles() { - return ''; - }, drawerHeightOffset() { - const wrapperEl = document.querySelector('.content-wrapper'); - return wrapperEl ? `${wrapperEl.offsetTop}px` : ''; + return getContentWrapperHeight(DRAWER_CONTAINER_CLASS); }, }, methods: { @@ -47,7 +50,7 @@ export default { <gl-drawer :header-height="drawerHeightOffset" :open="isVisible" - :z-index="200" + :z-index="zIndex" @close="closeDrawer" > <template #title> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue index 201fba837e2..b78224e93b0 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue @@ -1,24 +1,30 @@ <script> import { GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL } from '../../constants'; export default { i18n: { browseTemplates: __('Browse templates'), help: __('Help'), + jobAssistant: s__('JobAssistant|Job assistant'), }, TEMPLATE_REPOSITORY_URL, components: { GlButton, }, - mixins: [Tracking.mixin()], + mixins: [glFeatureFlagMixin(), Tracking.mixin()], props: { showDrawer: { type: Boolean, required: true, }, + showJobAssistantDrawer: { + type: Boolean, + required: true, + }, }, methods: { toggleDrawer() { @@ -29,6 +35,11 @@ export default { this.trackHelpDrawerClick(); } }, + toggleJobAssistantDrawer() { + this.$emit( + this.showJobAssistantDrawer ? 'close-job-assistant-drawer' : 'open-job-assistant-drawer', + ); + }, trackHelpDrawerClick() { const { label, actions } = pipelineEditorTrackingOptions; this.track(actions.openHelpDrawer, { label }); @@ -64,5 +75,15 @@ export default { > {{ $options.i18n.help }} </gl-button> + <gl-button + v-if="glFeatures.ciJobAssistantDrawer" + icon="bulb" + size="small" + data-testid="job-assistant-drawer-toggle" + data-qa-selector="job_assistant_drawer_toggle" + @click="toggleJobAssistantDrawer" + > + {{ $options.i18n.jobAssistant }} + </gl-button> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue index 84c29e48114..7368d1a3a91 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -52,7 +52,7 @@ export default { }; </script> <template> - <div class="gl-mb-4"> + <div class="gl-mb-4 gl-display-flex gl-flex-wrap gl-gap-3"> <gl-button v-if="showFileTreeToggle" id="file-tree-toggle" diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue index feadc60a22a..a4dfb401f4c 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue @@ -1,5 +1,6 @@ <script> import { __ } from '~/locale'; +import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; import { PIPELINE_FAILURE } from '../../constants'; @@ -43,7 +44,8 @@ export default { }, computed: { downstreamPipelines() { - return this.linkedPipelines?.downstream?.nodes || []; + const downstream = this.linkedPipelines?.downstream?.nodes; + return keepLatestDownstreamPipelines(downstream); }, hasPipelineStages() { return this.pipelineStages.length > 0; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js new file mode 100644 index 00000000000..1c122fd5e38 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js @@ -0,0 +1,7 @@ +import { s__ } from '~/locale'; + +export const DRAWER_CONTAINER_CLASS = '.content-wrapper'; + +export const i18n = { + ADD_JOB: s__('JobAssistant|Add job'), +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue new file mode 100644 index 00000000000..65c87df21cb --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue @@ -0,0 +1,62 @@ +<script> +import { GlDrawer, GlButton } from '@gitlab/ui'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; +import { DRAWER_CONTAINER_CLASS, i18n } from './constants'; + +export default { + i18n, + components: { + GlDrawer, + GlButton, + }, + props: { + isVisible: { + type: Boolean, + required: false, + default: false, + }, + zIndex: { + type: Number, + required: false, + default: 200, + }, + }, + computed: { + drawerHeightOffset() { + return getContentWrapperHeight(DRAWER_CONTAINER_CLASS); + }, + }, + methods: { + closeDrawer() { + this.$emit('close-job-assistant-drawer'); + }, + }, +}; +</script> +<template> + <gl-drawer + class="job-assistant-drawer" + :header-height="drawerHeightOffset" + :open="isVisible" + :z-index="zIndex" + @close="closeDrawer" + > + <template #title> + <h2 class="gl-m-0 gl-font-lg">{{ $options.i18n.ADD_JOB }}</h2> + </template> + <template #footer> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button + category="primary" + class="gl-mr-3" + data-testid="cancel-button" + @click="closeDrawer" + >{{ __('Cancel') }}</gl-button + > + <gl-button category="primary" variant="confirm" data-testid="confirm-button">{{ + __('Add') + }}</gl-button> + </div> + </template> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue index ed5466ff99c..fd6547468d9 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue @@ -95,6 +95,10 @@ export default { type: Boolean, required: true, }, + showJobAssistantDrawer: { + type: Boolean, + required: true, + }, }, apollo: { appStatus: { @@ -187,7 +191,11 @@ export default { @click="setCurrentTab($options.tabConstants.CREATE_TAB)" > <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" /> - <ci-editor-header :show-drawer="showDrawer" v-on="$listeners" /> + <ci-editor-header + :show-drawer="showDrawer" + :show-job-assistant-drawer="showJobAssistantDrawer" + v-on="$listeners" + /> <text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" /> </editor-tab> <editor-tab diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue index 1972125ed56..59863edbe0b 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue @@ -3,6 +3,7 @@ import { GlModal } from '@gitlab/ui'; import { __ } from '~/locale'; import CommitSection from './components/commit/commit_section.vue'; import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue'; +import JobAssistantDrawer from './components/job_assistant_drawer/job_assistant_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'; @@ -28,6 +29,7 @@ export default { CommitSection, GlModal, PipelineEditorDrawer, + JobAssistantDrawer, PipelineEditorFileNav, PipelineEditorFileTree, PipelineEditorHeader, @@ -63,6 +65,9 @@ export default { scrollToCommitForm: false, shouldLoadNewBranch: false, showDrawer: false, + showJobAssistantDrawer: false, + drawerIndex: 200, + jobAssistantIndex: 200, showFileTree: false, showSwitchBranchModal: false, }; @@ -85,11 +90,19 @@ export default { closeDrawer() { this.showDrawer = false; }, + closeJobAssistantDrawer() { + this.showJobAssistantDrawer = false; + }, handleConfirmSwitchBranch() { this.showSwitchBranchModal = true; }, openDrawer() { this.showDrawer = true; + this.drawerIndex = this.jobAssistantIndex + 1; + }, + openJobAssistantDrawer() { + this.showJobAssistantDrawer = true; + this.jobAssistantIndex = this.drawerIndex + 1; }, toggleFileTree() { this.showFileTree = !this.showFileTree; @@ -153,9 +166,12 @@ export default { :current-tab="currentTab" :is-new-ci-config-file="isNewCiConfigFile" :show-drawer="showDrawer" + :show-job-assistant-drawer="showJobAssistantDrawer" v-on="$listeners" @open-drawer="openDrawer" @close-drawer="closeDrawer" + @open-job-assistant-drawer="openJobAssistantDrawer" + @close-job-assistant-drawer="closeJobAssistantDrawer" @set-current-tab="setCurrentTab" @walkthrough-popover-cta-clicked="setScrollToCommitForm" /> @@ -174,8 +190,15 @@ export default { /> <pipeline-editor-drawer :is-visible="showDrawer" + :z-index="drawerIndex" v-on="$listeners" @close-drawer="closeDrawer" /> + <job-assistant-drawer + :is-visible="showJobAssistantDrawer" + :z-index="jobAssistantIndex" + v-on="$listeners" + @close-job-assistant-drawer="closeJobAssistantDrawer" + /> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue index 5692627abef..8837b7a1917 100644 --- a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue @@ -16,18 +16,29 @@ import { import * as Sentry from '@sentry/browser'; import { uniqueId } from 'lodash'; import Vue from 'vue'; +import { fetchPolicies } from '~/lib/graphql'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__, __, n__ } from '~/locale'; -import { VARIABLE_TYPE, FILE_TYPE, CC_VALIDATION_REQUIRED_ERROR } from '../constants'; +import { + CC_VALIDATION_REQUIRED_ERROR, + CONFIG_VARIABLES_TIMEOUT, + FILE_TYPE, + VARIABLE_TYPE, +} from '../constants'; import createPipelineMutation from '../graphql/mutations/create_pipeline.mutation.graphql'; import ciConfigVariablesQuery from '../graphql/queries/ci_config_variables.graphql'; import filterVariables from '../utils/filter_variables'; import RefsDropdown from './refs_dropdown.vue'; +let pollTimeout; +export const POLLING_INTERVAL = 2000; const i18n = { variablesDescription: s__( - 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', + 'Pipeline|Specify variable values to be used in this run. The variables specified in the configuration file as well as %{linkStart}CI/CD settings%{linkEnd} are used by default.', + ), + overrideNoteText: s__( + 'CiVariables|Variables specified here are %{boldStart}expanded%{boldEnd} and not %{boldStart}masked.%{boldEnd}', ), defaultError: __('Something went wrong on our end. Please try again.'), refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'), @@ -115,10 +126,11 @@ export default { // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined, }, + configVariablesWithDescription: {}, form: {}, errorTitle: null, error: null, - predefinedValueOptions: {}, + predefinedVariables: null, warnings: [], totalWarnings: 0, isWarningDismissed: false, @@ -128,6 +140,7 @@ export default { }, apollo: { ciConfigVariables: { + fetchPolicy: fetchPolicies.NO_CACHE, query: ciConfigVariablesQuery, // Skip when variables already cached in `form` skip() { @@ -140,46 +153,40 @@ export default { }; }, update({ project }) { - return project?.ciConfigVariables || []; + return project?.ciConfigVariables; }, result({ data }) { - const predefinedVars = data?.project?.ciConfigVariables || []; - const params = {}; - const descriptions = {}; - - predefinedVars.forEach(({ description, key, value, valueOptions }) => { - if (description) { - params[key] = value; - descriptions[key] = description; - this.predefinedValueOptions[key] = valueOptions; - } - }); - - Vue.set(this.form, this.refFullName, { descriptions, variables: [] }); + this.predefinedVariables = data?.project?.ciConfigVariables; - // Add default variables from yml - this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); - - // Add/update variables, e.g. from query string - if (this.variableParams) { - this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); + // API cache is empty when predefinedVariables === null, so we need to + // poll while cache values are being populated in the backend. + // After CONFIG_VARIABLES_TIMEOUT ms have passed, we stop polling + // and populate the form regardless. + if (this.isFetchingCiConfigVariables && !pollTimeout) { + pollTimeout = setTimeout(() => { + this.predefinedVariables = []; + this.clearPolling(); + this.populateForm(); + }, CONFIG_VARIABLES_TIMEOUT); } - if (this.fileParams) { - this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); + if (!this.isFetchingCiConfigVariables) { + this.clearPolling(); + this.populateForm(); } - - // Adds empty var at the end of the form - this.addEmptyVariable(this.refFullName); }, error(error) { Sentry.captureException(error); }, + pollInterval: POLLING_INTERVAL, }, }, computed: { + isFetchingCiConfigVariables() { + return this.predefinedVariables === null; + }, isLoading() { - return this.$apollo.queries.ciConfigVariables.loading; + return this.$apollo.queries.ciConfigVariables.loading || this.isFetchingCiConfigVariables; }, overMaxWarningsLimit() { return this.totalWarnings > this.maxWarnings; @@ -228,6 +235,48 @@ export default { value: '', }); }, + clearPolling() { + clearTimeout(pollTimeout); + this.$apollo.queries.ciConfigVariables.stopPolling(); + }, + populateForm() { + this.configVariablesWithDescription = this.predefinedVariables.reduce( + (accumulator, { description, key, value, valueOptions }) => { + if (description) { + accumulator.descriptions[key] = description; + accumulator.values[key] = value; + accumulator.options[key] = valueOptions; + } + + return accumulator; + }, + { descriptions: {}, values: {}, options: {} }, + ); + + Vue.set(this.form, this.refFullName, { + descriptions: this.configVariablesWithDescription.descriptions, + variables: [], + }); + + // Add default variables from yml + this.setVariableParams( + this.refFullName, + VARIABLE_TYPE, + this.configVariablesWithDescription.values, + ); + + // Add/update variables, e.g. from query string + if (this.variableParams) { + this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); + } + + if (this.fileParams) { + this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); + } + + // Adds empty var at the end of the form + this.addEmptyVariable(this.refFullName); + }, setVariable(refValue, type, key, value) { const { variables } = this.form[refValue]; @@ -255,7 +304,7 @@ export default { }); }, shouldShowValuesDropdown(key) { - return this.predefinedValueOptions[key]?.length > 1; + return this.configVariablesWithDescription.options[key]?.length > 1; }, removeVariable(index) { this.variables.splice(index, 1); @@ -362,7 +411,7 @@ export default { <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" /> - <gl-form-group v-else :label="s__('Pipeline|Variables')"> + <gl-form-group v-else class="gl-mb-3" :label="s__('Pipeline|Variables')"> <div v-for="(variable, index) in variables" :key="variable.uniqueId" @@ -403,13 +452,13 @@ export default { data-qa-selector="ci_variable_value_dropdown" > <gl-dropdown-item - v-for="value in predefinedValueOptions[variable.key]" - :key="value" + v-for="option in configVariablesWithDescription.options[variable.key]" + :key="option" data-testid="pipeline-form-ci-variable-value-dropdown-items" data-qa-selector="ci_variable_value_dropdown_item" - @click="setVariableAttribute(variable.key, 'value', value)" + @click="setVariableAttribute(variable.key, 'value', option)" > - {{ value }} + {{ option }} </gl-dropdown-item> </gl-dropdown> <gl-form-textarea @@ -457,6 +506,15 @@ export default { </gl-sprintf></template > </gl-form-group> + <div class="gl-mb-4 gl-text-gray-500"> + <gl-sprintf :message="$options.i18n.overrideNoteText"> + <template #bold="{ content }"> + <strong> + {{ content }} + </strong> + </template> + </gl-sprintf> + </div> <div class="gl-pt-5 gl-display-flex"> <gl-button type="submit" diff --git a/app/assets/javascripts/ci/reports/codequality_report/constants.js b/app/assets/javascripts/ci/reports/codequality_report/constants.js index 5e81245037f..e1486649dbb 100644 --- a/app/assets/javascripts/ci/reports/codequality_report/constants.js +++ b/app/assets/javascripts/ci/reports/codequality_report/constants.js @@ -1,10 +1,10 @@ export const SEVERITY_CLASSES = { - info: 'text-primary-400', - minor: 'text-warning-200', - major: 'text-warning-400', - critical: 'text-danger-600', - blocker: 'text-danger-800', - unknown: 'text-secondary-400', + info: 'gl-text-blue-400', + minor: 'gl-text-orange-200', + major: 'gl-text-orange-400', + critical: 'gl-text-red-600', + blocker: 'gl-text-red-800', + unknown: 'gl-text-gray-400', }; export const SEVERITY_ICONS = { diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue new file mode 100644 index 00000000000..5401c7c1c28 --- /dev/null +++ b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue @@ -0,0 +1,78 @@ +<script> +import { GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; +import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue'; +import { DEFAULT_PLATFORM, DEFAULT_ACCESS_LEVEL } from '../constants'; + +export default { + name: 'AdminNewRunnerApp', + components: { + GlLink, + GlSprintf, + RunnerInstructionsModal, + RunnerPlatformsRadioGroup, + RunnerFormFields, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + legacyRegistrationToken: { + type: String, + required: true, + }, + }, + data() { + return { + platform: DEFAULT_PLATFORM, + runner: { + description: '', + maintenanceNote: '', + paused: false, + accessLevel: DEFAULT_ACCESS_LEVEL, + runUntagged: false, + tagList: '', + maximumTimeout: ' ', + }, + }; + }, + modalId: 'runners-legacy-registration-instructions-modal', +}; +</script> + +<template> + <div> + <h1 class="gl-font-size-h2">{{ s__('Runners|New instance runner') }}</h1> + <p> + <gl-sprintf + :message=" + s__( + 'Runners|Create an instance runner to generate a command that registers the runner with all its configurations. %{linkStart}Prefer to use a registration token to create a runner?%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link v-gl-modal="$options.modalId" data-testid="legacy-instructions-link">{{ + content + }}</gl-link> + <runner-instructions-modal + :modal-id="$options.modalId" + :registration-token="legacyRegistrationToken" + /> + </template> + </gl-sprintf> + </p> + + <hr aria-hidden="true" /> + + <h2 class="gl-font-weight-normal gl-font-lg gl-my-5"> + {{ s__('Runners|Platform') }} + </h2> + <runner-platforms-radio-group v-model="platform" /> + + <hr aria-hidden="true" /> + + <runner-form-fields v-model="runner" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/index.js b/app/assets/javascripts/ci/runner/admin_new_runner/index.js new file mode 100644 index 00000000000..502d9d33b4d --- /dev/null +++ b/app/assets/javascripts/ci/runner/admin_new_runner/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import AdminNewRunnerApp from './admin_new_runner_app.vue'; + +Vue.use(VueApollo); + +export const initAdminNewRunner = (selector = '#js-admin-new-runner') => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { legacyRegistrationToken } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(AdminNewRunnerApp, { + props: { + legacyRegistrationToken, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue index 66d790acb00..8d4303778af 100644 --- a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue @@ -1,53 +1,29 @@ <script> -import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; -import VueRouter from 'vue-router'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; -import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { redirectTo } from '~/lib/utils/url_utility'; -import { formatJobCount } from '../utils'; + 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 RunnerJobs from '../components/runner_jobs.vue'; -import { I18N_DETAILS, I18N_JOBS, I18N_FETCH_ERROR } from '../constants'; +import RunnerDetailsTabs from '../components/runner_details_tabs.vue'; + +import { I18N_FETCH_ERROR } from '../constants'; import runnerQuery from '../graphql/show/runner.query.graphql'; import { captureException } from '../sentry_utils'; import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; -const ROUTE_DETAILS = 'details'; -const ROUTE_JOBS = 'jobs'; - -const routes = [ - { - path: '/', - name: ROUTE_DETAILS, - component: RunnerDetails, - }, - { - path: '/jobs', - name: ROUTE_JOBS, - component: RunnerJobs, - }, - { path: '*', redirect: { name: ROUTE_DETAILS } }, -]; - export default { name: 'AdminRunnerShowApp', components: { - GlBadge, - GlTabs, - GlTab, RunnerDeleteButton, RunnerEditButton, RunnerPauseButton, RunnerHeader, + RunnerDetailsTabs, }, - router: new VueRouter({ - routes, - }), props: { runnerId: { type: String, @@ -68,7 +44,7 @@ export default { query: runnerQuery, variables() { return { - id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), + id: convertToGraphQLId(TYPENAME_CI_RUNNER, this.runnerId), }; }, error(error) { @@ -85,20 +61,11 @@ export default { canDelete() { return this.runner.userPermissions?.deleteRunner; }, - jobCount() { - return formatJobCount(this.runner?.jobCount); - }, - tabIndex() { - return routes.findIndex(({ name }) => name === this.$route.name); - }, }, errorCaptured(error) { this.reportToSentry(error); }, methods: { - goTo(name) { - this.$router.push({ name }); - }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, @@ -107,10 +74,6 @@ export default { redirectTo(this.runnersPath); }, }, - ROUTE_DETAILS, - ROUTE_JOBS, - I18N_DETAILS, - I18N_JOBS, }; </script> <template> @@ -122,26 +85,6 @@ export default { <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" /> </template> </runner-header> - - <gl-tabs :value="tabIndex"> - <gl-tab @click="goTo($options.ROUTE_DETAILS)"> - <template #title>{{ $options.I18N_DETAILS }}</template> - </gl-tab> - <gl-tab @click="goTo($options.ROUTE_JOBS)"> - <template #title> - {{ $options.I18N_JOBS }} - <gl-badge - v-if="jobCount" - data-testid="job-count-badge" - class="gl-tab-counter-badge" - size="sm" - > - {{ jobCount }} - </gl-badge> - </template> - </gl-tab> - - <router-view v-if="runner" :runner="runner" /> - </gl-tabs> + <runner-details-tabs v-if="runner" :runner="runner" /> </div> </template> diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue index 3bd20dff9cc..ce2c511ddd4 100644 --- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue @@ -1,5 +1,5 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlButton, GlLink } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { updateHistory } from '~/lib/utils/url_utility'; import { fetchPolicies } from '~/lib/graphql'; @@ -33,12 +33,14 @@ import { INSTANCE_TYPE, I18N_FETCH_ERROR, FILTER_CSS_CLASSES, + JOBS_ROUTE_PATH, } from '../constants'; import { captureException } from '../sentry_utils'; export default { name: 'AdminRunnersApp', components: { + GlButton, GlLink, RegistrationDropdown, RunnerFilteredSearchBar, @@ -54,6 +56,10 @@ export default { mixins: [glFeatureFlagMixin()], inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], props: { + newRunnerPath: { + type: String, + required: true, + }, registrationToken: { type: String, required: true, @@ -121,6 +127,10 @@ export default { isSearchFiltered() { return isSearchFiltered(this.search); }, + shouldShowCreateRunnerWorkflow() { + // create_runner_workflow feature flag + return this.glFeatures.createRunnerWorkflow; + }, }, watch: { search: { @@ -141,7 +151,7 @@ export default { methods: { jobsUrl(runner) { const url = new URL(runner.adminUrl); - url.hash = '#/jobs'; + url.hash = `#${JOBS_ROUTE_PATH}`; return url.href; }, @@ -183,7 +193,11 @@ export default { nav-class="gl-border-none!" /> + <gl-button v-if="shouldShowCreateRunnerWorkflow" :href="newRunnerPath" variant="confirm"> + {{ s__('Runners|New instance runner') }} + </gl-button> <registration-dropdown + v-else class="gl-w-full gl-sm-w-auto gl-mr-auto" :registration-token="registrationToken" :type="$options.INSTANCE_TYPE" @@ -204,6 +218,7 @@ export default { v-if="noRunnersFound" :registration-token="registrationToken" :is-search-filtered="isSearchFiltered" + :new-runner-path="newRunnerPath" :svg-path="emptyStateSvgPath" :filtered-svg-path="emptyStateFilteredSvgPath" /> @@ -214,17 +229,17 @@ export default { :checkable="true" @deleted="onDeleted" > - <template #runner-name="{ runner }"> - <gl-link :href="runner.adminUrl"> - <runner-name :runner="runner" /> - </gl-link> - </template> <template #runner-job-status-badge="{ runner }"> <runner-job-status-badge :href="jobsUrl(runner)" :job-status="runner.jobExecutionStatus" /> </template> + <template #runner-name="{ runner }"> + <gl-link :href="runner.adminUrl"> + <runner-name :runner="runner" /> + </gl-link> + </template> <template #runner-actions-cell="{ runner }"> <runner-actions-cell :runner="runner" diff --git a/app/assets/javascripts/ci/runner/admin_runners/index.js b/app/assets/javascripts/ci/runner/admin_runners/index.js index c6db7148eb1..881dc3613e9 100644 --- a/app/assets/javascripts/ci/runner/admin_runners/index.js +++ b/app/assets/javascripts/ci/runner/admin_runners/index.js @@ -31,6 +31,7 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { const { runnerInstallHelpPage, + newRunnerPath, registrationToken, onlineContactTimeoutSecs, staleTimeoutSecs, @@ -58,6 +59,7 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { render(h) { return h(AdminRunnersApp, { props: { + newRunnerPath, registrationToken, }, }); diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue index cfbe37f5ba2..4d04b5d4b14 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue @@ -36,5 +36,6 @@ export default { v-if="paused" class="gl-display-inline-block gl-max-w-full gl-text-truncate" /> + <slot :runner="runner" name="runner-job-status-badge"></slot> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue index 4a72023b6a0..97dfbe1a051 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue @@ -6,7 +6,6 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerName from '../runner_name.vue'; import RunnerTags from '../runner_tags.vue'; import RunnerTypeBadge from '../runner_type_badge.vue'; -import RunnerJobStatusBadge from '../runner_job_status_badge.vue'; import { formatJobCount } from '../../utils'; import { @@ -27,7 +26,6 @@ export default { RunnerName, RunnerTags, RunnerTypeBadge, - RunnerJobStatusBadge, RunnerUpgradeStatusIcon: () => import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), TooltipOnTruncate, @@ -90,10 +88,6 @@ export default { </div> <div> - <slot :runner="runner" name="runner-job-status-badge"> - <runner-job-status-badge :job-status="runner.jobExecutionStatus" /> - </slot> - <runner-summary-field icon="clock"> <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL"> <template #timeAgo> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue index 6740065e860..ac2793654c8 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue @@ -1,7 +1,7 @@ <script> import { GlDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants'; +import { TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; import runnersRegistrationTokenResetMutation from '~/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql'; @@ -58,12 +58,12 @@ export default { }; case GROUP_TYPE: return { - id: convertToGraphQLId(TYPE_GROUP, this.groupId), + id: convertToGraphQLId(TYPENAME_GROUP, this.groupId), type: this.type, }; case PROJECT_TYPE: return { - id: convertToGraphQLId(TYPE_PROJECT, this.projectId), + id: convertToGraphQLId(TYPENAME_PROJECT, this.projectId), type: this.type, }; default: diff --git a/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue b/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue index 2fa87bdd776..5e61e4d7377 100644 --- a/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue +++ b/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue @@ -55,7 +55,7 @@ export default { <div> <div class="gl-mb-1"> <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link> - <gl-badge v-if="isOwner" variant="info">{{ s__('Runner|Owner') }}</gl-badge> + <gl-badge v-if="isOwner" variant="info">{{ s__('Runners|Owner') }}</gl-badge> </div> <div v-if="description">{{ description }}</div> </div> diff --git a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue index 1ec3f8da7c3..8dde3ac4e19 100644 --- a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue +++ b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue @@ -162,22 +162,28 @@ export default { </script> <template> - <div v-if="checkedCount" class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100"> - <div class="gl-display-flex gl-align-items-center"> - <div> - <gl-sprintf :message="bannerMessage"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </div> - <div class="gl-ml-auto"> - <gl-button variant="default" @click="onClearChecked">{{ - s__('Runners|Clear selection') - }}</gl-button> - <gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">{{ - s__('Runners|Delete selected') - }}</gl-button> + <div> + <div + v-if="checkedCount" + data-testid="runner-bulk-delete-banner" + class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100" + > + <div class="gl-display-flex gl-align-items-center"> + <div> + <gl-sprintf :message="bannerMessage"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </div> + <div class="gl-ml-auto"> + <gl-button variant="default" @click="onClearChecked">{{ + s__('Runners|Clear selection') + }}</gl-button> + <gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">{{ + s__('Runners|Delete selected') + }}</gl-button> + </div> </div> </div> <gl-modal diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue index 32d4076b00f..f02e6bce5c3 100644 --- a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue +++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue @@ -122,7 +122,7 @@ export default { onError(error) { this.deleting = false; const { message } = error; - const title = sprintf(s__('Runner|Runner %{runnerName} failed to delete'), { + const title = sprintf(s__('Runners|Runner %{runnerName} failed to delete'), { runnerName: this.runnerName, }); diff --git a/app/assets/javascripts/ci/runner/components/runner_details_tabs.vue b/app/assets/javascripts/ci/runner/components/runner_details_tabs.vue new file mode 100644 index 00000000000..e4190a4dffd --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_details_tabs.vue @@ -0,0 +1,95 @@ +<script> +import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; +import VueRouter from 'vue-router'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { JOBS_ROUTE_PATH, I18N_DETAILS, I18N_JOBS } from '../constants'; +import { formatJobCount } from '../utils'; +import RunnerDetails from './runner_details.vue'; +import RunnerJobs from './runner_jobs.vue'; + +const ROUTE_DETAILS = 'details'; +const ROUTE_JOBS = 'jobs'; + +const routes = [ + { + path: '/', + name: ROUTE_DETAILS, + component: RunnerDetails, + }, + { + path: JOBS_ROUTE_PATH, + name: ROUTE_JOBS, + component: RunnerJobs, + }, + { path: '*', redirect: { name: ROUTE_DETAILS } }, +]; + +export default { + name: 'RunnerDetailsTabs', + components: { + GlBadge, + GlTabs, + GlTab, + HelpPopover, + }, + router: new VueRouter({ + routes, + }), + props: { + runner: { + type: Object, + required: false, + default: null, + }, + showAccessHelp: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + jobCount() { + return formatJobCount(this.runner?.jobCount); + }, + tabIndex() { + return routes.findIndex(({ name }) => name === this.$route.name); + }, + }, + methods: { + goTo(name) { + if (this.$route.name !== name) { + this.$router.push({ name }); + } + }, + }, + ROUTE_DETAILS, + ROUTE_JOBS, + I18N_DETAILS, + I18N_JOBS, +}; +</script> +<template> + <gl-tabs :value="tabIndex"> + <gl-tab @click="goTo($options.ROUTE_DETAILS)"> + <template #title>{{ $options.I18N_DETAILS }}</template> + </gl-tab> + <gl-tab @click="goTo($options.ROUTE_JOBS)"> + <template #title> + {{ $options.I18N_JOBS }} + <gl-badge + v-if="jobCount" + data-testid="job-count-badge" + class="gl-tab-counter-badge" + size="sm" + > + {{ jobCount }} + </gl-badge> + <help-popover v-if="showAccessHelp" class="gl-ml-3"> + {{ s__('Runners|Jobs in projects you have access to.') }} + </help-popover> + </template> + </gl-tab> + + <router-view v-if="runner" :runner="runner" /> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_form_fields.vue b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue new file mode 100644 index 00000000000..e37ac5e6e26 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue @@ -0,0 +1,140 @@ +<script> +import { GlFormGroup, GlFormCheckbox, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED } from '../constants'; + +export default { + name: 'RunnerFormFields', + components: { + GlFormGroup, + GlFormCheckbox, + GlFormInput, + GlLink, + GlSprintf, + RunnerMaintenanceNoteField: () => + import('ee_component/ci/runner/components/runner_maintenance_note_field.vue'), + }, + props: { + value: { + type: Object, + default: null, + required: false, + }, + }, + data() { + return { + model: { + ...this.value, + }, + }; + }, + watch: { + model: { + handler() { + this.$emit('input', this.model); + }, + deep: true, + }, + }, + HELP_LABELS_PAGE_PATH: helpPagePath('ci/runners/configure_runners', { + anchor: 'use-tags-to-control-which-jobs-a-runner-can-run', + }), + ACCESS_LEVEL_NOT_PROTECTED, + ACCESS_LEVEL_REF_PROTECTED, +}; +</script> +<template> + <div> + <h2 class="gl-font-weight-normal gl-font-lg gl-my-5"> + {{ s__('Runners|Details') }} + {{ __('(optional)') }} + </h2> + <gl-form-group :label="s__('Runners|Runner description')" label-for="runner-description"> + <gl-form-input id="runner-description" v-model="model.description" name="description" /> + </gl-form-group> + + <runner-maintenance-note-field v-model="model.maintenanceNote" class="gl-mt-5" /> + + <hr aria-hidden="true" /> + + <h2 class="gl-font-weight-normal gl-font-lg gl-my-5"> + {{ s__('Runners|Configuration') }} + {{ __('(optional)') }} + </h2> + + <div class="gl-mb-5"> + <gl-form-checkbox v-model="model.paused" name="paused"> + {{ __('Paused') }} + <template #help> + {{ s__('Runners|Stop the runner from accepting new jobs.') }} + </template> + </gl-form-checkbox> + + <gl-form-checkbox + v-model="model.accessLevel" + name="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-checkbox v-model="model.runUntagged" name="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> + </div> + + <gl-form-group :label="__('Tags')" label-for="runner-tags"> + <template #description> + <gl-sprintf + :message=" + s__('Runners|Multiple tags must be separated by a comma. For example, %{example}.') + " + > + <template #example> + <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> + <code>macos, shared</code> + </template> + </gl-sprintf> + </template> + <template #label-description> + <gl-sprintf + :message=" + s__( + 'Runners|Add tags for the types of jobs the runner processes to ensure that the runner only runs jobs that you intend it to. %{helpLinkStart}Learn more.%{helpLinkEnd}', + ) + " + > + <template #helpLink="{ content }"> + <gl-link :href="$options.HELP_LABELS_PAGE_PATH" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + <gl-form-input id="runner-tags" v-model="model.tagList" name="tags" /> + </gl-form-group> + + <gl-form-group + :label="__('Maximum job timeout')" + :label-description=" + s__( + 'Runners|Maximum amount of time the runner can run before it terminates. If a project has a shorter job timeout period, the job timeout period of the instance runner is used instead.', + ) + " + label-for="runner-max-timeout" + :description="s__('Runners|Enter the number of seconds.')" + > + <gl-form-input + id="runner-max-timeout" + v-model.number="model.maximumTimeout" + name="max-timeout" + type="number" + /> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue index 1e52acecfb8..bed592e3f30 100644 --- a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue +++ b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue @@ -45,8 +45,7 @@ export default { <gl-badge v-if="badge" v-bind="$attrs" - size="sm" - class="gl-mr-3 gl-bg-transparent!" + class="gl-display-inline-block gl-max-w-full gl-text-truncate gl-bg-transparent!" variant="muted" :class="badge.classes" > diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue index e359344ab77..ebcda4f0ac3 100644 --- a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue +++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue @@ -37,10 +37,10 @@ export default { return job.detailedStatus?.detailsPath; }, projectName(job) { - return job.pipeline?.project?.name; + return job.project?.name; }, projectWebUrl(job) { - return job.pipeline?.project?.webUrl; + return job.project?.webUrl; }, commitShortSha(job) { return job.shortSha; diff --git a/app/assets/javascripts/ci/runner/components/runner_list.vue b/app/assets/javascripts/ci/runner/components/runner_list.vue index b2aad0aac4f..ec04701db2c 100644 --- a/app/assets/javascripts/ci/runner/components/runner_list.vue +++ b/app/assets/javascripts/ci/runner/components/runner_list.vue @@ -150,16 +150,17 @@ export default { </template> <template #cell(status)="{ item }"> - <runner-status-cell :runner="item" /> + <runner-status-cell :runner="item"> + <template #runner-job-status-badge="{ runner }"> + <slot name="runner-job-status-badge" :runner="runner"></slot> + </template> + </runner-status-cell> </template> - <template #cell(summary)="{ item, index }"> + <template #cell(summary)="{ item }"> <runner-summary-cell :runner="item"> <template #runner-name="{ runner }"> - <slot name="runner-name" :runner="runner" :index="index"></slot> - </template> - <template #runner-job-status-badge="{ runner }"> - <slot name="runner-job-status-badge" :runner="runner" :index="index"></slot> + <slot name="runner-name" :runner="runner"></slot> </template> </runner-summary-cell> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue index e6576c83e69..d2f7912fabb 100644 --- a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue +++ b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue @@ -1,5 +1,6 @@ <script> import { GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; export default { @@ -12,6 +13,7 @@ export default { directives: { GlModal: GlModalDirective, }, + mixins: [glFeatureFlagMixin()], props: { isSearchFiltered: { type: Boolean, @@ -33,6 +35,17 @@ export default { required: false, default: null, }, + newRunnerPath: { + type: String, + required: false, + default: null, + }, + }, + computed: { + shouldShowCreateRunnerWorkflow() { + // create_runner_workflow feature flag + return this.newRunnerPath && this.glFeatures?.createRunnerWorkflow; + }, }, modalId: 'runners-empty-state-instructions-modal', svgHeight: 145, @@ -61,15 +74,17 @@ export default { ) " > - <template #link="{ content }"> + <template v-if="shouldShowCreateRunnerWorkflow" #link="{ content }"> + <gl-link :href="newRunnerPath">{{ content }}</gl-link> + </template> + <template v-else #link="{ content }"> <gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link> + <runner-instructions-modal + :modal-id="$options.modalId" + :registration-token="registrationToken" + /> </template> </gl-sprintf> - - <runner-instructions-modal - :modal-id="$options.modalId" - :registration-token="registrationToken" - /> </template> <template v-else #description> {{ diff --git a/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue new file mode 100644 index 00000000000..d70c51e83f9 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue @@ -0,0 +1,76 @@ +<script> +import { GlFormRadio } from '@gitlab/ui'; + +export default { + components: { + GlFormRadio, + }, + model: { + event: 'input', + prop: 'checked', + }, + props: { + image: { + type: String, + required: false, + default: null, + }, + checked: { + type: String, + required: false, + default: null, + }, + value: { + type: String, + required: false, + default: null, + }, + }, + computed: { + isChecked() { + return this.value && this.value === this.checked; + }, + }, + methods: { + onInput($event) { + if (!$event) { + return; + } + this.$emit('input', $event); + }, + onChange($event) { + this.$emit('change', $event); + }, + }, +}; +</script> + +<template> + <div + class="runner-platforms-radio gl-display-flex gl-border gl-rounded-base gl-px-5 gl-py-6" + :class="{ 'gl-bg-blue-50 gl-border-blue-500': isChecked, 'gl-cursor-pointer': value }" + @click="onInput(value)" + > + <gl-form-radio + v-if="value" + class="gl-min-h-5" + :checked="checked" + :value="value" + @input="onInput($event)" + @change="onChange($event)" + > + <img v-if="image" :src="image" aria-hidden="true" class="gl-h-5 gl-mr-2" /> + <span class="gl-font-weight-bold"><slot></slot></span> + </gl-form-radio> + <div v-else class="gl-h-5"> + <img v-if="image" :src="image" aria-hidden="true" class="gl-h-5 gl-mr-2" /> + <span class="gl-font-weight-bold"><slot></slot></span> + </div> + </div> +</template> + +<style> +.runner-platforms-radio { + min-width: 173px; +} +</style> diff --git a/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue new file mode 100644 index 00000000000..273226141d2 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue @@ -0,0 +1,108 @@ +<script> +import AWS_LOGO_URL from '@gitlab/svgs/dist/illustrations/logos/aws.svg?url'; +import DOCKER_LOGO_URL from '@gitlab/svgs/dist/illustrations/third-party-logos/ci_cd-template-logos/docker.png'; +import KUBERNETES_LOGO_URL from '@gitlab/svgs/dist/illustrations/logos/kubernetes.svg?url'; +import { GlFormRadioGroup, GlIcon, GlLink } from '@gitlab/ui'; + +import { + LINUX_PLATFORM, + MACOS_PLATFORM, + WINDOWS_PLATFORM, + AWS_PLATFORM, + DOCKER_HELP_URL, + KUBERNETES_HELP_URL, +} from '../constants'; + +import RunnerPlatformsRadio from './runner_platforms_radio.vue'; + +export default { + components: { + GlFormRadioGroup, + GlLink, + GlIcon, + RunnerPlatformsRadio, + }, + props: { + value: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + model: this.value, + }; + }, + watch: { + model() { + this.$emit('input', this.model); + }, + }, + LINUX_PLATFORM, + MACOS_PLATFORM, + WINDOWS_PLATFORM, + + AWS_PLATFORM, + AWS_LOGO_URL, + DOCKER_HELP_URL, + DOCKER_LOGO_URL, + KUBERNETES_HELP_URL, + KUBERNETES_LOGO_URL, +}; +</script> + +<template> + <gl-form-radio-group v-model="model"> + <div class="gl-mt-3 gl-mb-6"> + <label>{{ s__('Runners|Operating systems') }}</label> + + <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <!-- eslint-disable @gitlab/vue-require-i18n-strings --> + <runner-platforms-radio v-model="model" :value="$options.LINUX_PLATFORM"> + Linux + </runner-platforms-radio> + <runner-platforms-radio v-model="model" :value="$options.MACOS_PLATFORM"> + macOS + </runner-platforms-radio> + <runner-platforms-radio v-model="model" :value="$options.WINDOWS_PLATFORM"> + Windows + </runner-platforms-radio> + </div> + </div> + + <div class="gl-mt-3 gl-mb-6"> + <label>{{ s__('Runners|Cloud templates') }}</label> + <!-- eslint-disable @gitlab/vue-require-i18n-strings --> + <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <runner-platforms-radio + v-model="model" + :image="$options.AWS_LOGO_URL" + :value="$options.AWS_PLATFORM" + > + AWS + </runner-platforms-radio> + </div> + </div> + + <div class="gl-mt-3 gl-mb-6"> + <label>{{ s__('Runners|Containers') }}</label> + + <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <!-- eslint-disable @gitlab/vue-require-i18n-strings --> + <runner-platforms-radio :image="$options.DOCKER_LOGO_URL"> + <gl-link :href="$options.DOCKER_HELP_URL" target="_blank"> + Docker + <gl-icon name="external-link" /> + </gl-link> + </runner-platforms-radio> + <runner-platforms-radio :image="$options.KUBERNETES_LOGO_URL"> + <gl-link :href="$options.KUBERNETES_HELP_URL" target="_blank"> + Kubernetes + <gl-icon name="external-link" /> + </gl-link> + </runner-platforms-radio> + </div> + </div> + </gl-form-radio-group> +</template> diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index 31900a1fe89..318eb7e74bd 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -98,6 +98,8 @@ export const I18N_ADMIN = s__('Runners|Administrator'); // Runner details +export const JOBS_ROUTE_PATH = '/jobs'; // vue-router route path + export const I18N_DETAILS = s__('Runners|Details'); export const I18N_JOBS = s__('Runners|Jobs'); export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})'); @@ -150,6 +152,8 @@ export const JOB_STATUS_IDLE = 'IDLE'; export const ACCESS_LEVEL_NOT_PROTECTED = 'NOT_PROTECTED'; export const ACCESS_LEVEL_REF_PROTECTED = 'REF_PROTECTED'; +export const DEFAULT_ACCESS_LEVEL = ACCESS_LEVEL_NOT_PROTECTED; + // CiRunnerSort export const CREATED_DESC = 'CREATED_DESC'; @@ -170,3 +174,17 @@ export const DEFAULT_MEMBERSHIP = MEMBERSHIP_DESCENDANTS; export const ADMIN_FILTERED_SEARCH_NAMESPACE = 'admin_runners'; export const GROUP_FILTERED_SEARCH_NAMESPACE = 'group_runners'; + +// Platforms + +export const LINUX_PLATFORM = 'linux'; +export const MACOS_PLATFORM = 'osx'; +export const WINDOWS_PLATFORM = 'windows'; +export const AWS_PLATFORM = 'aws'; + +export const DEFAULT_PLATFORM = LINUX_PLATFORM; + +// Runner docs are in a separate repository and are not shipped with GitLab +// they are rendered as external URLs. +export const DOCKER_HELP_URL = 'https://docs.gitlab.com/runner/install/docker.html'; +export const KUBERNETES_HELP_URL = 'https://docs.gitlab.com/runner/install/kubernetes.html'; diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql index 075dbb06190..b6d6996a857 100644 --- a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql @@ -15,13 +15,10 @@ query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, icon text } - pipeline { + project { id - project { - id - name - webUrl - } + name + webUrl } shortSha commitPath diff --git a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue index 75138b1bd81..273a9aa823c 100644 --- a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue +++ b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue @@ -1,13 +1,15 @@ <script> import { createAlert, VARIANT_SUCCESS } from '~/flash'; -import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { TYPENAME_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 RunnerDetailsTabs from '../components/runner_details_tabs.vue'; + import { I18N_FETCH_ERROR } from '../constants'; import runnerQuery from '../graphql/show/runner.query.graphql'; import { captureException } from '../sentry_utils'; @@ -20,7 +22,7 @@ export default { RunnerEditButton, RunnerPauseButton, RunnerHeader, - RunnerDetails, + RunnerDetailsTabs, }, props: { runnerId: { @@ -47,7 +49,7 @@ export default { query: runnerQuery, variables() { return { - id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), + id: convertToGraphQLId(TYPENAME_CI_RUNNER, this.runnerId), }; }, error(error) { @@ -89,6 +91,6 @@ export default { </template> </runner-header> - <runner-details v-if="runner" :runner="runner" /> + <runner-details-tabs :runner="runner" :show-access-help="true" /> </div> </template> diff --git a/app/assets/javascripts/ci/runner/group_runner_show/index.js b/app/assets/javascripts/ci/runner/group_runner_show/index.js index e75f337b38e..a6c1ee1d232 100644 --- a/app/assets/javascripts/ci/runner/group_runner_show/index.js +++ b/app/assets/javascripts/ci/runner/group_runner_show/index.js @@ -1,10 +1,12 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; import GroupRunnerShowApp from './group_runner_show_app.vue'; Vue.use(VueApollo); +Vue.use(VueRouter); export const initGroupRunnerShow = (selector = '#js-group-runner-show') => { showAlertFromLocalStorage(); diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue index 57ceaa24b6e..e66a1c7b1aa 100644 --- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue @@ -24,6 +24,7 @@ import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; import RunnerActionsCell from '../components/cells/runner_actions_cell.vue'; import RunnerMembershipToggle from '../components/runner_membership_toggle.vue'; +import RunnerJobStatusBadge from '../components/runner_job_status_badge.vue'; import { pausedTokenConfig } from '../components/search_tokens/paused_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; @@ -34,6 +35,7 @@ import { PROJECT_TYPE, I18N_FETCH_ERROR, FILTER_CSS_CLASSES, + JOBS_ROUTE_PATH, } from '../constants'; import { captureException } from '../sentry_utils'; @@ -51,6 +53,7 @@ export default { RunnerPagination, RunnerTypeTabs, RunnerActionsCell, + RunnerJobStatusBadge, }, mixins: [glFeatureFlagMixin()], inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], @@ -64,10 +67,6 @@ export default { type: String, required: true, }, - groupRunnersLimitedCount: { - type: Number, - required: true, - }, }, data() { return { @@ -175,6 +174,12 @@ export default { editUrl(runner) { return this.runners.urlsById[runner.id]?.edit; }, + jobsUrl(runner) { + const url = new URL(this.webUrl(runner)); + url.hash = `#${JOBS_ROUTE_PATH}`; + + return url.href; + }, refetchCounts() { this.$apollo.getClient().refetchQueries({ include: [groupRunnersCountQuery] }); }, @@ -255,6 +260,12 @@ export default { :loading="runnersLoading" @deleted="onDeleted" > + <template #runner-job-status-badge="{ runner }"> + <runner-job-status-badge + :href="jobsUrl(runner)" + :job-status="runner.jobExecutionStatus" + /> + </template> <template #runner-name="{ runner }"> <gl-link :href="webUrl(runner)"> <runner-name :runner="runner" /> diff --git a/app/assets/javascripts/ci/runner/group_runners/index.js b/app/assets/javascripts/ci/runner/group_runners/index.js index 0e7efd2b8a1..46514d5afe8 100644 --- a/app/assets/javascripts/ci/runner/group_runners/index.js +++ b/app/assets/javascripts/ci/runner/group_runners/index.js @@ -20,7 +20,6 @@ export const initGroupRunners = (selector = '#js-group-runners') => { runnerInstallHelpPage, groupId, groupFullPath, - groupRunnersLimitedCount, onlineContactTimeoutSecs, staleTimeoutSecs, emptyStateSvgPath, @@ -50,7 +49,6 @@ export const initGroupRunners = (selector = '#js-group-runners') => { props: { registrationToken, groupFullPath, - groupRunnersLimitedCount: parseInt(groupRunnersLimitedCount, 10), }, }); }, diff --git a/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue index 879162916a9..4593c9ae52b 100644 --- a/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue +++ b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue @@ -1,6 +1,6 @@ <script> import { createAlert } from '~/flash'; -import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '../components/runner_header.vue'; import RunnerUpdateForm from '../components/runner_update_form.vue'; @@ -35,7 +35,7 @@ export default { query: runnerFormQuery, variables() { return { - id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), + id: convertToGraphQLId(TYPENAME_CI_RUNNER, this.runnerId), }; }, error(error) { diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/button.vue b/app/assets/javascripts/ci_secure_files/components/metadata/button.vue new file mode 100644 index 00000000000..799c6ec79d4 --- /dev/null +++ b/app/assets/javascripts/ci_secure_files/components/metadata/button.vue @@ -0,0 +1,54 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, + }, + props: { + secureFile: { + type: Object, + required: true, + }, + admin: { + type: Boolean, + required: true, + }, + modalId: { + type: String, + required: true, + }, + }, + i18n: { + metadataLabel: __('View File Metadata'), + }, + metadataModalId: 'metadataModalId', + methods: { + selectSecureFile() { + this.$emit('selectSecureFile', this.secureFile); + }, + hasMetadata() { + return this.secureFile.metadata !== null; + }, + }, +}; +</script> + +<template> + <gl-button + v-if="admin && hasMetadata()" + v-gl-modal="modalId" + v-gl-tooltip.hover.top="$options.i18n.metadataLabel" + category="secondary" + variant="info" + icon="doc-text" + :aria-label="$options.i18n.metadataLabel" + data-testid="metadata-button" + @click="selectSecureFile()" + /> +</template> diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue b/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue new file mode 100644 index 00000000000..a459b721394 --- /dev/null +++ b/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue @@ -0,0 +1,129 @@ +<script> +import { GlModal, GlSprintf, GlModalDirective } from '@gitlab/ui'; +import { __, s__, createDateTimeFormat } from '~/locale'; +import Tracking from '~/tracking'; +import MetadataTable from './table.vue'; + +const dateFormat = createDateTimeFormat({ + dateStyle: 'long', + timeStyle: 'long', +}); + +export default { + components: { + GlModal, + GlSprintf, + MetadataTable, + }, + directives: { + GlModal: GlModalDirective, + }, + mixins: [Tracking.mixin()], + props: { + name: { + type: String, + required: false, + default: '', + }, + fileExtension: { + type: String, + required: false, + default: '', + }, + metadata: { + type: Object, + required: false, + default: Object.new, + }, + modalId: { + type: String, + required: true, + }, + }, + i18n: { + metadataLabel: __('View File Metadata'), + metadataModalTitle: s__('SecureFiles|%{name} Metadata'), + }, + metadataModalId: 'metadataModalId', + methods: { + teamName() { + return `${this.metadata.subject.O} (${this.metadata.subject.OU})`; + }, + issuerName() { + return [this.metadata.issuer.CN, '-', this.metadata.issuer.OU].join(' '); + }, + expiresAt() { + return dateFormat.format(new Date(this.metadata.expires_at)); + }, + mobileprovisionTeamName() { + return `${this.metadata.team_name} (${this.metadata.team_id.join(', ')})`; + }, + platformNames() { + return this.metadata.platforms.join(', '); + }, + appName() { + return [this.metadata.app_name, '-', this.metadata.app_id].join(' '); + }, + certificates() { + return this.metadata.certificate_ids.join(', '); + }, + cerItems() { + return [ + { name: s__('SecureFiles|Name'), data: this.metadata.subject.CN }, + { name: s__('SecureFiles|Serial'), data: this.metadata.id }, + { name: s__('SecureFiles|Team'), data: this.teamName() }, + { name: s__('SecureFiles|Issuer'), data: this.issuerName() }, + { name: s__('SecureFiles|Expires at'), data: this.expiresAt() }, + ]; + }, + p12Items() { + return [ + { name: s__('SecureFiles|Name'), data: this.metadata.subject.CN }, + { name: s__('SecureFiles|Serial'), data: this.metadata.id }, + { name: s__('SecureFiles|Team'), data: this.teamName() }, + { name: s__('SecureFiles|Issuer'), data: this.issuerName() }, + { name: s__('SecureFiles|Expires at'), data: this.expiresAt() }, + ]; + }, + mobileprovisionItems() { + return [ + { name: s__('SecureFiles|UUID'), data: this.metadata.id }, + { name: s__('SecureFiles|Platforms'), data: this.platformNames() }, + { name: s__('SecureFiles|Team'), data: this.mobileprovisionTeamName() }, + { name: s__('SecureFiles|App'), data: this.appName() }, + { name: s__('SecureFiles|Certificates'), data: this.certificates() }, + { name: s__('SecureFiles|Expires at'), data: this.expiresAt() }, + ]; + }, + items() { + if (this.metadata) { + if (this.fileExtension === 'cer') { + this.track('load_secure_file_metadata_cer'); + return this.cerItems(); + } else if (this.fileExtension === 'p12') { + this.track('load_secure_file_metadata_p12'); + return this.p12Items(); + } else if (this.fileExtension === 'mobileprovision') { + this.track('load_secure_file_metadata_mobileprovision'); + return this.mobileprovisionItems(this.metadata); + } + } + + return []; + }, + }, +}; +</script> +`` + +<template> + <gl-modal :ref="modalId" :modal-id="modalId" title-tag="h4" category="primary" hide-footer> + <template #modal-title> + <gl-sprintf :message="$options.i18n.metadataModalTitle"> + <template #name>{{ name }}</template> + </gl-sprintf> + </template> + + <metadata-table :items="items()" /> + </gl-modal> +</template> diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/table.vue b/app/assets/javascripts/ci_secure_files/components/metadata/table.vue new file mode 100644 index 00000000000..92043ff0a31 --- /dev/null +++ b/app/assets/javascripts/ci_secure_files/components/metadata/table.vue @@ -0,0 +1,36 @@ +<script> +import { GlTableLite } from '@gitlab/ui'; + +export default { + components: { + GlTableLite, + }, + props: { + items: { + required: true, + type: Array, + }, + }, + fields: [ + { + key: 'item_name', + thClass: 'hidden', + }, + { + key: 'item_data', + thClass: 'hidden', + }, + ], +}; +</script> + +<template> + <gl-table-lite :items="items" :fields="$options.fields"> + <template #cell(item_name)="{ item }"> + <strong>{{ item.name }}</strong> + </template> + <template #cell(item_data)="{ item }"> + {{ item.data }} + </template> + </gl-table-lite> +</template> 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 661389f4059..dd80698ec1a 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 @@ -17,6 +17,8 @@ import { HTTP_STATUS_PAYLOAD_TOO_LARGE } from '~/lib/utils/http_status'; import { __, s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import MetadataButton from './metadata/button.vue'; +import MetadataModal from './metadata/modal.vue'; export default { components: { @@ -29,6 +31,8 @@ export default { GlSprintf, GlTable, TimeagoTooltip, + MetadataButton, + MetadataModal, }, directives: { GlTooltip: GlTooltipDirective, @@ -57,6 +61,7 @@ export default { deleteModalButton: s__('SecureFiles|Delete secure file'), }, deleteModalId: 'deleteModalId', + metadataModalId: 'metadataModalId', data() { return { page: 1, @@ -68,6 +73,7 @@ export default { projectSecureFiles: [], deleteModalFileId: null, deleteModalFileName: null, + metadataSecureFile: {}, }; }, fields: [ @@ -162,6 +168,9 @@ export default { this.deleteModalFileId = secureFile.id; this.deleteModalFileName = secureFile.name; }, + updateMetadataSecureFile(secureFile) { + this.metadataSecureFile = secureFile; + }, uploadFormData(file) { const formData = new FormData(); formData.append('name', file.name); @@ -208,6 +217,12 @@ export default { </template> <template #cell(actions)="{ item }"> + <metadata-button + :secure-file="item" + :admin="admin" + modal-id="$options.metadataModalId" + @selectSecureFile="updateMetadataSecureFile" + /> <gl-button v-if="admin" v-gl-modal="$options.deleteModalId" @@ -272,5 +287,12 @@ export default { <template #name>{{ deleteModalFileName }}</template> </gl-sprintf> </gl-modal> + + <metadata-modal + :name="metadataSecureFile.name" + :file-extension="metadataSecureFile.file_extension" + :metadata="metadataSecureFile.metadata" + modal-id="$options.metadataModalId" + /> </div> </template> diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue index 1f8096da94d..a1b264cfe54 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -8,7 +8,8 @@ import { GlTable, GlTooltipDirective, } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { thWidthPercent } from '~/lib/utils/table_utility'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; @@ -43,43 +44,70 @@ export default { default: () => [], }, }, + data() { + return { + areValuesHidden: true, + }; + }, fields: [ { key: 'token', label: s__('Pipelines|Token'), + thClass: thWidthPercent(70), }, { key: 'description', label: s__('Pipelines|Description'), + thClass: thWidthPercent(15), }, { key: 'owner', label: s__('Pipelines|Owner'), + thClass: thWidthPercent(5), }, { key: 'lastUsed', label: s__('Pipelines|Last Used'), + thClass: thWidthPercent(5), }, { key: 'actions', label: '', tdClass: 'gl-text-right gl-white-space-nowrap', + thClass: thWidthPercent(5), }, ], + computed: { + valuesButtonText() { + return this.areValuesHidden ? __('Reveal values') : __('Hide values'); + }, + hasTriggers() { + return this.triggers.length; + }, + maskedToken() { + return '*'.repeat(47); + }, + }, + methods: { + toggleHiddenState() { + this.areValuesHidden = !this.areValuesHidden; + }, + }, }; </script> <template> <div> <gl-table - v-if="triggers.length" + v-if="hasTriggers" :fields="$options.fields" :items="triggers" class="triggers-list" responsive > <template #cell(token)="{ item }"> - {{ item.token }} + <span v-if="!areValuesHidden">{{ item.token }}</span> + <span v-else>{{ maskedToken }}</span> <clipboard-button v-if="item.hasTokenExposed" :text="item.token" @@ -157,5 +185,11 @@ export default { > {{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }} </gl-alert> + <gl-button + v-if="hasTriggers" + data-testid="reveal-hide-values-button" + @click="toggleHiddenState" + >{{ valuesButtonText }}</gl-button + > </div> </template> diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js index 76af552181f..e97d6500260 100644 --- a/app/assets/javascripts/clusters/agents/constants.js +++ b/app/assets/javascripts/clusters/agents/constants.js @@ -22,7 +22,7 @@ export const EVENT_DETAILS = { body: s__('ClusterAgents|Agent %{strongStart}connected%{strongEnd}'), titleIcon: { name: 'status-success', - class: 'text-success-500', + class: 'gl-text-green-500', }, }, agent_disconnected: { @@ -31,7 +31,7 @@ export const EVENT_DETAILS = { body: s__('ClusterAgents|Agent %{strongStart}disconnected%{strongEnd}'), titleIcon: { name: 'severity-critical', - class: 'text-danger-800', + class: 'gl-text-red-800', }, }, }; @@ -50,12 +50,12 @@ export const REVOKE_TOKEN_MODAL_ID = 'revoke-token-%{tokenName}'; export const INTEGRATION_STATUS_VALID_TOKEN = { icon: 'status-success', - iconClass: 'text-success-500', + iconClass: 'gl-text-green-500', text: s__('ClusterAgents|Valid access token'), }; export const INTEGRATION_STATUS_NO_TOKEN = { icon: 'status-alert', - iconClass: 'text-danger-500', + iconClass: 'gl-text-red-500', text: s__('ClusterAgents|No agent access token'), }; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 21524c5b29e..a788703fd08 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -3,11 +3,11 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; import { createAlert } from '~/flash'; import AccessorUtilities from '~/lib/utils/accessor'; -import initProjectSelectDropdown from '~/project_select'; import Poll from '~/lib/utils/poll'; import { s__ } from '~/locale'; import PersistentUserCallout from '~/persistent_user_callout'; import initSettingsPanels from '~/settings_panels'; +import { initProjectSelects } from '~/vue_shared/components/entity_select/init_project_selects'; import RemoveClusterConfirmation from './components/remove_cluster_confirmation.vue'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; @@ -62,7 +62,7 @@ export default class Clusters { this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason'); this.tokenField = document.querySelector('.js-cluster-token'); - initProjectSelectDropdown(); + initProjectSelects(); Clusters.initDismissableCallout(); initSettingsPanels(); diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 615754459d6..fe3fa22fea3 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -144,7 +144,7 @@ export const AGENT_STATUSES = { active: { name: s__('ClusterAgents|Connected'), icon: 'status-success', - class: 'text-success-500', + class: 'gl-text-green-500', tooltip: { title: sprintf(s__('ClusterAgents|Last connected %{timeAgo}.')), }, @@ -152,7 +152,7 @@ export const AGENT_STATUSES = { inactive: { name: s__('ClusterAgents|Not connected'), icon: 'status-alert', - class: 'text-danger-500', + class: 'gl-text-red-500', tooltip: { title: s__('ClusterAgents|Agent might not be connected to GitLab'), body: sprintf( @@ -165,7 +165,7 @@ export const AGENT_STATUSES = { unused: { name: s__('ClusterAgents|Never connected'), icon: 'status-neutral', - class: 'text-secondary-500', + class: 'gl-text-gray-500', tooltip: { title: s__('ClusterAgents|Agent never connected to GitLab'), body: s__('ClusterAgents|Make sure you are using a valid token.'), diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue index 9cb7cd9607f..c937e65abe3 100644 --- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue +++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue @@ -1,11 +1,10 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { __ } from '~/locale'; export default { components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, }, props: { projects: { @@ -19,32 +18,37 @@ export default { }, }, computed: { - dropdownText() { - if (Object.keys(this.selectedProject).length) { - return this.selectedProject.name; - } - - return __('Select private project'); + selectedProjectValue() { + return this.selectedProject?.id && String(this.selectedProject.id); + }, + toggleText() { + return this.selectedProject?.name || __('Select private project'); + }, + listboxItems() { + return this.projects.map(({ id, name }) => { + return { + value: String(id), + text: name, + }; + }); }, }, methods: { - selectProject(project) { - this.$emit('click', project); + selectProject(projectId) { + const project = this.projects.find(({ id }) => String(id) === projectId); + this.$emit('select', project); }, }, }; </script> <template> - <gl-dropdown block icon="lock" :text="dropdownText"> - <gl-dropdown-item - v-for="project in projects" - :key="project.id" - is-check-item - :is-checked="project.id === selectedProject.id" - @click="selectProject(project)" - > - {{ project.name }} - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox + icon="lock" + :items="listboxItems" + :selected="selectedProjectValue" + :toggle-text="toggleText" + block + @select="selectProject" + /> </template> 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 e95424eef4d..196f5537a90 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 @@ -114,7 +114,7 @@ export default { v-if="projects.length" :projects="projects" :selected-project="selectedProject" - @click="selectProject" + @select="selectProject" /> <p class="gl-text-gray-600 gl-mt-1 gl-mb-0"> <template v-if="projects.length"> diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue index 93b31ea7d20..ca17443081c 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue @@ -1,18 +1,63 @@ <script> -import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { GlTooltip, GlDisclosureDropdown } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; + +import { __ } from '~/locale'; export default { components: { - GlDropdown, - GlDropdownItem, - }, - directives: { + GlDisclosureDropdown, GlTooltip, }, inject: ['tiptapEditor'], data() { return { - isActive: {}, + toggleId: uniqueId('dropdown-toggle-btn-'), + items: [ + { + text: __('Comment'), + action: () => this.insert('comment'), + }, + { + text: __('Code block'), + action: () => this.insert('codeBlock'), + }, + { + text: __('Details block'), + action: () => this.insertList('details', 'detailsContent'), + }, + { + text: __('Bullet list'), + action: () => this.insertList('bulletList', 'listItem'), + wrapperClass: 'gl-sm-display-none!', + }, + { + text: __('Ordered list'), + action: () => this.insertList('orderedList', 'listItem'), + wrapperClass: 'gl-sm-display-none!', + }, + { + text: __('Task list'), + action: () => this.insertList('taskList', 'taskItem'), + wrapperClass: 'gl-sm-display-none!', + }, + { + text: __('Horizontal rule'), + action: () => this.execute('setHorizontalRule', 'horizontalRule'), + }, + { + text: __('Mermaid diagram'), + action: () => this.insert('diagram', { language: 'mermaid' }), + }, + { + text: __('PlantUML diagram'), + action: () => this.insert('diagram', { language: 'plantuml' }), + }, + { + text: __('Table of contents'), + action: () => this.execute('insertTableOfContents', 'tableOfContents'), + }, + ], }; }, methods: { @@ -46,47 +91,17 @@ export default { }; </script> <template> - <gl-dropdown - v-gl-tooltip - size="small" - category="tertiary" - icon="plus" - :text="__('More')" - :title="__('More')" - text-sr-only - class="content-editor-dropdown" - right - lazy - > - <gl-dropdown-item @click="insert('comment')"> - {{ __('Comment') }} - </gl-dropdown-item> - <gl-dropdown-item @click="insert('codeBlock')"> - {{ __('Code block') }} - </gl-dropdown-item> - <gl-dropdown-item @click="insertList('details', 'detailsContent')"> - {{ __('Details block') }} - </gl-dropdown-item> - <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('bulletList', 'listItem')"> - {{ __('Bullet list') }} - </gl-dropdown-item> - <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('orderedList', 'listItem')"> - {{ __('Ordered list') }} - </gl-dropdown-item> - <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('taskList', 'taskItem')"> - {{ __('Task list') }} - </gl-dropdown-item> - <gl-dropdown-item @click="execute('setHorizontalRule', 'horizontalRule')"> - {{ __('Horizontal rule') }} - </gl-dropdown-item> - <gl-dropdown-item @click="insert('diagram', { language: 'mermaid' })"> - {{ __('Mermaid diagram') }} - </gl-dropdown-item> - <gl-dropdown-item @click="insert('diagram', { language: 'plantuml' })"> - {{ __('PlantUML diagram') }} - </gl-dropdown-item> - <gl-dropdown-item @click="execute('insertTableOfContents', 'tableOfContents')"> - {{ __('Table of contents') }} - </gl-dropdown-item> - </gl-dropdown> + <div class="gl-display-inline-flex gl-vertical-align-middle"> + <gl-disclosure-dropdown + :items="items" + :toggle-id="toggleId" + size="small" + category="tertiary" + icon="plus" + :toggle-text="__('More options')" + text-sr-only + right + /> + <gl-tooltip :target="toggleId" placement="top">{{ __('More options') }}</gl-tooltip> + </div> </template> diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 131c79357bf..540815f57c9 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -514,6 +514,7 @@ export const code = { open: generateCodeTag(), close: generateCodeTag(closeTag), mixable: true, + escape: false, expelEnclosingWhitespace: true, }; diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue index 4e4c21328ca..17e6cc87ff8 100644 --- a/app/assets/javascripts/contributors/components/contributors.vue +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -1,19 +1,33 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { debounce, uniq } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; +import { visitUrl } from '~/lib/utils/url_utility'; import { getDatesInRange } from '~/lib/utils/datetime_utility'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { __ } from '~/locale'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue'; import { xAxisLabelFormatter, dateFormatter } from '../utils'; +const GRAPHS_PATH_REGEX = /^(.*?)\/-\/graphs/g; + export default { + i18n: { + history: __('History'), + refSelectorTranslations: { + dropdownHeader: __('Switch branch/tag'), + searchPlaceholder: __('Search branches and tags'), + }, + }, components: { GlAreaChart, + GlButton, GlLoadingIcon, ResizableChartContainer, + RefSelector, }, props: { endpoint: { @@ -24,7 +38,16 @@ export default { type: String, required: true, }, + projectId: { + type: String, + required: true, + }, + commitsPath: { + type: String, + required: true, + }, }, + refTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS], data() { return { masterChart: null, @@ -32,6 +55,7 @@ export default { svgs: {}, masterChartHeight: 264, individualChartHeight: 216, + selectedBranch: this.branch, }; }, computed: { @@ -190,6 +214,11 @@ export default { ), ); }, + visitBranch(selected) { + const graphsPathPrefix = this.endpoint.match(GRAPHS_PATH_REGEX)?.[0]; + + visitUrl(`${graphsPathPrefix}/${selected}`); + }, }, }; </script> @@ -197,48 +226,66 @@ export default { <template> <div> <div v-if="loading" class="gl-text-center gl-pt-13"> - <gl-loading-icon :inline="true" size="xl" /> + <gl-loading-icon :inline="true" size="xl" data-testid="loading-app-icon" /> </div> - <div v-else-if="showChart" class="contributors-charts"> - <h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4> - <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span> - <resizable-chart-container> - <template #default="{ width }"> - <gl-area-chart - class="gl-mb-5" - :width="width" - :data="masterChartData" - :option="masterChartOptions" - :height="masterChartHeight" - @created="onMasterChartCreated" - /> - </template> - </resizable-chart-container> + <template v-else-if="showChart"> + <div class="gl-border-b gl-border-gray-100 gl-mb-6 gl-bg-gray-10 gl-p-5"> + <div class="gl-display-flex"> + <div class="gl-mr-3"> + <ref-selector + v-model="selectedBranch" + :project-id="projectId" + :enabled-ref-types="$options.refTypes" + :translations="$options.i18n.refSelectorTranslations" + @input="visitBranch" + /> + </div> + <gl-button :href="commitsPath" data-testid="history-button" + >{{ $options.i18n.history }} + </gl-button> + </div> + </div> + <div data-testid="contributors-charts"> + <h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4> + <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span> + <resizable-chart-container> + <template #default="{ width }"> + <gl-area-chart + class="gl-mb-5" + :width="width" + :data="masterChartData" + :option="masterChartOptions" + :height="masterChartHeight" + @created="onMasterChartCreated" + /> + </template> + </resizable-chart-container> - <div class="row"> - <div - v-for="(contributor, index) in individualChartsData" - :key="index" - class="col-lg-6 col-12 gl-my-5" - > - <h4 class="gl-mb-2 gl-mt-0">{{ contributor.name }}</h4> - <p class="gl-mb-3"> - {{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }}) - </p> - <resizable-chart-container> - <template #default="{ width }"> - <gl-area-chart - :width="width" - :data="contributor.dates" - :option="individualChartOptions" - :height="individualChartHeight" - @created="onIndividualChartCreated" - /> - </template> - </resizable-chart-container> + <div class="row"> + <div + v-for="(contributor, index) in individualChartsData" + :key="index" + class="col-lg-6 col-12 gl-my-5" + > + <h4 class="gl-mb-2 gl-mt-0">{{ contributor.name }}</h4> + <p class="gl-mb-3"> + {{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }}) + </p> + <resizable-chart-container> + <template #default="{ width }"> + <gl-area-chart + :width="width" + :data="contributor.dates" + :option="individualChartOptions" + :height="individualChartHeight" + @created="onIndividualChartCreated" + /> + </template> + </resizable-chart-container> + </div> </div> </div> - </div> + </template> </div> </template> diff --git a/app/assets/javascripts/contributors/index.js b/app/assets/javascripts/contributors/index.js index f66133a074d..1bb7360547c 100644 --- a/app/assets/javascripts/contributors/index.js +++ b/app/assets/javascripts/contributors/index.js @@ -7,18 +7,19 @@ export default () => { if (!el) return null; - const { projectGraphPath, projectBranch, defaultBranch } = el.dataset; + const { projectGraphPath, projectBranch, defaultBranch, projectId, commitsPath } = el.dataset; const store = createStore(defaultBranch); return new Vue({ el, store, - render(createElement) { return createElement(ContributorsGraphs, { props: { endpoint: projectGraphPath, branch: projectBranch, + projectId, + commitsPath, }, }); }, 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 a851c7a9e85..57931121629 100644 --- a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue +++ b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue @@ -1,7 +1,7 @@ <script> import { s__, __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_CRM_CONTACT, TYPE_GROUP } from '~/graphql_shared/constants'; +import { TYPENAME_CRM_CONTACT, TYPENAME_GROUP } from '~/graphql_shared/constants'; import CrmForm from '../../components/crm_form.vue'; import getGroupOrganizationsQuery from '../../organizations/components/graphql/get_group_organizations.query.graphql'; import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql'; @@ -44,10 +44,10 @@ export default { contactGraphQLId() { if (!this.isEditMode) return null; - return convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id); + return convertToGraphQLId(TYPENAME_CRM_CONTACT, this.$route.params.id); }, groupGraphQLId() { - return convertToGraphQLId(TYPE_GROUP, this.groupId); + return convertToGraphQLId(TYPENAME_GROUP, this.groupId); }, mutation() { if (this.isEditMode) return updateContactMutation; diff --git a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue index 01bff4b69d6..4d2a038458d 100644 --- a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue +++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue @@ -1,7 +1,7 @@ <script> import { s__, __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_CRM_ORGANIZATION, TYPE_GROUP } from '~/graphql_shared/constants'; +import { TYPENAME_CRM_ORGANIZATION, TYPENAME_GROUP } from '~/graphql_shared/constants'; import CrmForm from '../../components/crm_form.vue'; import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql'; import createOrganizationMutation from './graphql/create_organization.mutation.graphql'; @@ -23,10 +23,10 @@ export default { organizationGraphQLId() { if (!this.isEditMode) return null; - return convertToGraphQLId(TYPE_CRM_ORGANIZATION, this.$route.params.id); + return convertToGraphQLId(TYPENAME_CRM_ORGANIZATION, this.$route.params.id); }, groupGraphQLId() { - return convertToGraphQLId(TYPE_GROUP, this.groupId); + return convertToGraphQLId(TYPENAME_GROUP, this.groupId); }, mutation() { if (this.isEditMode) return updateOrganizationMutation; diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 8019a10a042..7503df9194b 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -17,6 +17,7 @@ import { escape, uniqueId } from 'lodash'; import Vue from 'vue'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { createAlert, VARIANT_INFO } from '~/flash'; +import { sanitize } from '~/lib/dompurify'; import '~/lib/utils/jquery_at_who'; import AjaxCache from '~/lib/utils/ajax_cache'; import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; @@ -517,8 +518,36 @@ export default class Notes { if (discussionContainer.length === 0) { if (noteEntity.diff_discussion_html) { const discussionElement = document.createElement('table'); - // eslint-disable-next-line no-unsanitized/method - discussionElement.insertAdjacentHTML('afterbegin', noteEntity.diff_discussion_html); + let internalNote; + let discussionDOM; + + if (!noteEntity.on_image) { + /* + DOMPurify will strip table-less <tr>/<td>, so to get it to stop deleting + nodes (since our note HTML starts with a table-less <tr>), we need to wrap + the noteEntity discussion HTML in a <table> to perform the other + sanitization. + */ + internalNote = sanitize(`<table>${noteEntity.diff_discussion_html}</table>`, { + RETURN_DOM: true, + }); + /* + Since we wrapped the <tr> in a <table>, we need to extract the <tr> back out. + DOMPurify returns a Body Element, so we have to start there, then get the + wrapping table, and then get the content we actually want. + Curiously, DOMPurify **ADDS** a totally novel <tbody>, so we're actually + inserting a completely as-yet-unseen <tbody> element here. + */ + discussionDOM = internalNote.querySelector('table').firstChild; + } else { + // Image comments don't need <table> manipulation, they're already <div>s + internalNote = sanitize(noteEntity.diff_discussion_html, { + RETURN_DOM: true, + }); + discussionDOM = internalNote.firstChild; + } + + discussionElement.insertAdjacentElement('afterbegin', discussionDOM); renderGFM(discussionElement); const $discussion = $(discussionElement).unwrap(); diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue index 674415ec449..4ce6395140e 100644 --- a/app/assets/javascripts/design_management/components/design_overlay.vue +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -262,6 +262,7 @@ export default { <div class="gl-absolute gl-top-0 gl-left-0 frame" :style="overlayStyle" + data-testid="design-overlay" @mousemove="onOverlayMousemove" @mouseleave="onNoteMouseup" > @@ -287,6 +288,7 @@ export default { :is-inactive="isNoteInactive(note)" :is-resolved="note.resolved" is-on-image + data-testid="note-pin" @mousedown.stop="onNoteMousedown($event, note)" @mouseup.stop="onNoteMouseup(note)" /> @@ -294,6 +296,7 @@ export default { <design-note-pin v-if="currentCommentForm" :position="currentCommentPositionStyle" + data-testid="comment-badge" @mousedown.stop="onNoteMousedown" @mouseup.stop="onNoteMouseup" /> diff --git a/app/assets/javascripts/design_management/components/image.vue b/app/assets/javascripts/design_management/components/image.vue index 5354c7756f5..fd691d1f04e 100644 --- a/app/assets/javascripts/design_management/components/image.vue +++ b/app/assets/javascripts/design_management/components/image.vue @@ -72,12 +72,19 @@ export default { }, setBaseImageSize() { const { contentImg } = this.$refs; - if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return; + if (!contentImg) return; + if (contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) { + this.baseImageSize = { + height: contentImg.naturalHeight, + width: contentImg.naturalWidth, + }; + } else { + this.baseImageSize = { + height: contentImg.offsetHeight, + width: contentImg.offsetWidth, + }; + } - this.baseImageSize = { - height: contentImg.offsetHeight, - width: contentImg.offsetWidth, - }; this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height }); }, setImageNaturalScale() { @@ -96,6 +103,11 @@ export default { const { height, width } = this.baseImageSize; + this.imageStyle = { + width: `${width}px`, + height: `${height}px`, + }; + this.$parent.$emit( 'setMaxScale', Math.round(((height + width) / (naturalHeight + naturalWidth)) * 100) / 100, diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue index 1e36aa686a4..f52486f0629 100644 --- a/app/assets/javascripts/design_management/components/list/item.vue +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -64,17 +64,17 @@ export default { const icons = { creation: { name: 'file-addition-solid', - classes: 'text-success-500', + classes: 'gl-text-green-500', tooltip: __('Added in this version'), }, modification: { name: 'file-modified-solid', - classes: 'text-primary-500', + classes: 'gl-text-blue-500', tooltip: __('Modified in this version'), }, deletion: { name: 'file-deletion-solid', - classes: 'text-danger-500', + classes: 'gl-text-red-500', tooltip: __('Archived in this version'), }, }; @@ -144,7 +144,7 @@ export default { /> </span> </div> - <gl-intersection-observer @appear="onAppear"> + <gl-intersection-observer class="gl-flex-grow-1" @appear="onAppear"> <gl-loading-icon v-if="showLoadingSpinner" size="lg" /> <gl-icon v-else-if="showImageErrorIcon" @@ -156,7 +156,7 @@ export default { v-show="showImage" :src="imageLink" :alt="filename" - class="gl-display-block gl-mx-auto gl-max-w-full gl-max-h-full design-img" + class="gl-display-block gl-mx-auto gl-max-w-full gl-max-h-full gl-w-auto design-img" data-qa-selector="design_image" :data-qa-filename="filename" :data-testid="`design-img-${id}`" diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js index 8c44c5a5d0a..cef2d5e1a18 100644 --- a/app/assets/javascripts/design_management/graphql.js +++ b/app/assets/javascripts/design_management/graphql.js @@ -14,7 +14,7 @@ import { CREATE_DESIGN_TODO_EXISTS_ERROR } from './utils/error_messages'; Vue.use(VueApollo); -const resolvers = { +export const resolvers = { Mutation: { updateActiveDiscussion: (_, { id = null, source }, { cache }) => { const sourceData = cache.readQuery({ query: activeDiscussionQuery }); diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index e5695c4390f..dfca6d61270 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -60,6 +60,16 @@ export default { type: Boolean, required: true, }, + isFirstHighlightedLine: { + type: Boolean, + required: false, + default: false, + }, + isLastHighlightedLine: { + type: Boolean, + required: false, + default: false, + }, fileLineCoverage: { type: Function, required: true, @@ -81,12 +91,23 @@ export default { ), parallelViewLeftLineType: memoize( (props) => { - return utils.parallelViewLeftLineType(props.line, props.isHighlighted || props.isCommented); + return utils.parallelViewLeftLineType({ + line: props.line, + highlighted: props.isHighlighted, + commented: props.isCommented, + selectionStart: props.isFirstHighlightedLine, + selectionEnd: props.isLastHighlightedLine, + }); }, (props) => - [props.line.left?.type, props.line.right?.type, props.isHighlighted, props.isCommented].join( - ':', - ), + [ + props.line.left?.type, + props.line.right?.type, + props.isHighlighted, + props.isCommented, + props.isFirstHighlightedLine, + props.isLastHighlightedLine, + ].join(':'), ), coverageStateLeft: memoize( (props) => { @@ -118,20 +139,40 @@ export default { classNameMapCellLeft: memoize( (props) => { return utils.classNameMapCell({ - line: props.line.left, - hll: props.isHighlighted || props.isCommented, + line: props.line?.left, + highlighted: props.isHighlighted, + commented: props.isCommented, + selectionStart: props.isFirstHighlightedLine, + selectionEnd: props.isLastHighlightedLine, }); }, - (props) => [props.line.left.type, props.isHighlighted, props.isCommented].join(':'), + (props) => + [ + props.line?.left?.type, + props.isHighlighted, + props.isCommented, + props.isFirstHighlightedLine, + props.isLastHighlightedLine, + ].join(':'), ), classNameMapCellRight: memoize( (props) => { return utils.classNameMapCell({ - line: props.line.right, - hll: props.isHighlighted || props.isCommented, + line: props.line?.right, + highlighted: props.isHighlighted, + commented: props.isCommented, + selectionStart: props.isFirstHighlightedLine, + selectionEnd: props.isLastHighlightedLine, }); }, - (props) => [props.line.right.type, props.isHighlighted, props.isCommented].join(':'), + (props) => + [ + props.line?.right?.type, + props.isHighlighted, + props.isCommented, + props.isFirstHighlightedLine, + props.isLastHighlightedLine, + ].join(':'), ), shouldRenderCommentButton: memoize( (props) => { @@ -303,15 +344,24 @@ export default { !props.inline || (props.line.left && props.line.left.type === $options.CONFLICT_MARKER) " > - <div data-testid="left-empty-cell" class="diff-td diff-line-num old_line empty-cell"> + <div + data-testid="left-empty-cell" + class="diff-td diff-line-num old_line empty-cell" + :class="$options.classNameMapCellLeft(props)" + > </div> - <div v-if="props.inline" class="diff-td diff-line-num old_line empty-cell"></div> - <div class="diff-td line-coverage left-side empty-cell"></div> - <div v-if="props.inline" class="diff-td line-codequality left-side empty-cell"></div> + <div + class="diff-td line-coverage left-side empty-cell" + :class="$options.classNameMapCellLeft(props)" + ></div> + <div + class="diff-td line-codequality left-side empty-cell" + :class="$options.classNameMapCellLeft(props)" + ></div> <div class="diff-td line_content with-coverage left-side empty-cell" - :class="[{ parallel: !props.inline }]" + :class="[{ parallel: !props.inline }, ...$options.classNameMapCellLeft(props)]" ></div> </template> </div> @@ -390,13 +440,13 @@ export default { :class="[ props.line.right.type, $options.coverageStateRight(props).class, - { hll: props.isHighlighted, hll: props.isCommented }, + ...$options.classNameMapCellRight(props), ]" class="diff-td line-coverage right-side has-tooltip" ></div> <div class="diff-td line-codequality right-side" - :class="[props.line.right.type, { hll: props.isHighlighted, hll: props.isCommented }]" + :class="$options.classNameMapCellRight(props)" > <component :is="$options.CodeQualityGutterIcon" @@ -414,10 +464,9 @@ export default { :class="[ props.line.right.type, { - hll: props.isHighlighted, - hll: props.isCommented, 'gl-font-weight-bold': props.line.right.type === $options.CONFLICT_MARKER_THEIR, }, + ...$options.classNameMapCellRight(props), ]" class="diff-td line_content with-coverage right-side parallel" v-html=" @@ -426,10 +475,23 @@ export default { ></div> </template> <template v-else> - <div data-testid="right-empty-cell" class="diff-td diff-line-num old_line empty-cell"></div> - <div class="diff-td line-coverage right-side empty-cell"></div> - <div class="diff-td line-codequality right-side empty-cell"></div> - <div class="diff-td line_content with-coverage right-side empty-cell parallel"></div> + <div + data-testid="right-empty-cell" + class="diff-td diff-line-num old_line empty-cell" + :class="$options.classNameMapCellRight(props)" + ></div> + <div + class="diff-td line-coverage right-side empty-cell" + :class="$options.classNameMapCellRight(props)" + ></div> + <div + class="diff-td line-codequality right-side empty-cell" + :class="$options.classNameMapCellRight(props)" + ></div> + <div + class="diff-td line_content with-coverage right-side empty-cell parallel" + :class="$options.classNameMapCellRight(props)" + ></div> </template> </div> </div> diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js index 479853caae3..a489c96b0c9 100644 --- a/app/assets/javascripts/diffs/components/diff_row_utils.js +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -40,19 +40,33 @@ export const lineCode = (line) => { return line.line_code || line.left?.line_code || line.right?.line_code; }; -export const classNameMapCell = ({ line, hll, isLoggedIn, isHover }) => { - if (!line) return []; - const { type } = line; +export const classNameMapCell = ({ + line, + highlighted, + commented, + selectionStart, + selectionEnd, + isLoggedIn, + isHover, +}) => { + const classes = { + 'highlight-top': highlighted || selectionStart, + 'highlight-bottom': highlighted || selectionEnd, + hll: highlighted, + commented, + }; - return [ - type, - { - hll, + if (line) { + const { type } = line; + Object.assign(classes, { + [type]: true, [LINE_HOVER_CLASS_NAME]: isLoggedIn && isHover && !isContextLine(type) && !isMetaLine(type), - old_line: line.type === 'old', - new_line: line.type === 'new', - }, - ]; + old_line: type === 'old', + new_line: type === 'new', + }); + } + + return [classes]; }; export const addCommentTooltip = (line) => { @@ -88,14 +102,28 @@ export const addCommentTooltip = (line) => { return tooltip; }; -export const parallelViewLeftLineType = (line, hll) => { +export const parallelViewLeftLineType = ({ + line, + highlighted, + commented, + selectionStart, + selectionEnd, +}) => { if (line?.right?.type === NEW_NO_NEW_LINE_TYPE) { return OLD_NO_NEW_LINE_TYPE; } const lineTypeClass = line?.left ? line.left.type : EMPTY_CELL_TYPE; - return [lineTypeClass, { hll }]; + return [ + lineTypeClass, + { + hll: highlighted, + commented, + 'highlight-top': highlighted || selectionStart, + 'highlight-bottom': highlighted || selectionEnd, + }, + ]; }; export const shouldShowCommentButton = (hover, context, meta, discussions) => { diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index aa9a17d18e3..a2e052e0f93 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -59,7 +59,12 @@ export default { }, computed: { ...mapGetters('diffs', ['commitId', 'fileLineCoverage']), - ...mapState('diffs', ['codequalityDiff', 'highlightedRow', 'coverageLoaded']), + ...mapState('diffs', [ + 'codequalityDiff', + 'highlightedRow', + 'coverageLoaded', + 'selectedCommentPosition', + ]), ...mapState({ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, @@ -144,6 +149,14 @@ export default { false, ); }, + isFirstHighlightedLine(line) { + const lineCode = line.left?.line_code || line.right?.line_code; + return lineCode && lineCode === this.selectedCommentPosition?.start.line_code; + }, + isLastHighlightedLine(line) { + const lineCode = line.left?.line_code || line.right?.line_code; + return lineCode && lineCode === this.selectedCommentPosition?.end.line_code; + }, handleParallelLineMouseDown(e) { const line = e.target.closest('.diff-td'); if (line) { @@ -230,10 +243,14 @@ export default { :line="line" :is-bottom="index + 1 === diffLinesLength" :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine" + :is-highlighted="isHighlighted(line)" + :is-first-highlighted-line=" + isFirstHighlightedLine(line) || index === commentedLines.startLine + " + :is-last-highlighted-line="isLastHighlightedLine(line) || index === commentedLines.endLine" :inline="inline" :index="index" :code-quality-expanded="codeQualityExpandedLines.includes(getCodeQualityLine(line))" - :is-highlighted="isHighlighted(line)" :file-line-coverage="fileLineCoverage" :coverage-loaded="coverageLoaded" @showCommentForm="(code) => singleLineComment(code, line)" diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index abf77fa2ede..8bb1872567c 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -101,7 +101,7 @@ export default { </button> </div> </div> - <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll"> + <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList || search }" class="tree-list-scroll"> <template v-if="filteredTreeList.length"> <file-tree v-for="file in filteredTreeList" @@ -112,6 +112,9 @@ export default { :hide-file-stats="hideFileStats" :file-row-component="$options.DiffFileRow" :current-diff-file-id="currentDiffFileId" + :style="{ '--level': 0 }" + :class="{ 'tree-list-parent': file.tree.length }" + class="gl-relative" @toggleTreeOpen="toggleTreeOpen" @clickFile="(path) => scrollToFile({ path })" /> diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index e6f7a31e07b..f90d29c84b8 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -87,8 +87,8 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { const processingFileCount = this.getQueuedFiles().length + this.getUploadingFiles().length; const shouldPad = processingFileCount >= 1; + addFileToForm(response.link.url, header.size); pasteText(response.link.markdown, shouldPad); - addFileToForm(response.link.url); }, error: (file, errorMessage = __('Attaching the file failed.'), xhr) => { // If 'error' event is fired by dropzone, the second parameter is error message. diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 87d869cc996..57477a993c5 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -417,6 +417,20 @@ "type": "object", "additionalProperties": false, "properties": { + "component": { + "description": "Local path to component directory or full path to external component directory.", + "type": "string", + "format": "uri-reference" + } + }, + "required": [ + "component" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { "remote": { "description": "URL to a `yaml`/`yml` template file using HTTP/HTTPS.", "type": "string", @@ -777,7 +791,7 @@ "properties": { "value": { "type": "string", - "markdownDescription": "Default value of the variable. If used with `options`, `value` must be included in the array. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/index.html#prefill-variables-in-manual-pipelines)" + "markdownDescription": "Default value of the variable. If used with `options`, `value` must be included in the array. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variablesvalue)" }, "options": { "type": "array", @@ -786,7 +800,7 @@ }, "minItems": 1, "uniqueItems": true, - "markdownDescription": "A list of predefined values that users can select from in the **Run pipeline** page when running a pipeline manually. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/index.html#configure-a-list-of-selectable-values-for-a-prefilled-variable)" + "markdownDescription": "A list of predefined values that users can select from in the **Run pipeline** page when running a pipeline manually. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variablesoptions)" }, "description": { "type": "string", @@ -1955,4 +1969,4 @@ "additionalProperties": false } } -}
\ No newline at end of file +} diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue index 1bac0ef1359..ee5d95ae6f0 100644 --- a/app/assets/javascripts/environments/components/environment_form.vue +++ b/app/assets/javascripts/environments/components/environment_form.vue @@ -3,6 +3,10 @@ import { GlButton, GlForm, GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@ import { helpPagePath } from '~/helpers/help_page_helper'; import { isAbsolute } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { + ENVIRONMENT_NEW_HELP_TEXT, + ENVIRONMENT_EDIT_HELP_TEXT, +} from 'ee_else_ce/environments/constants'; export default { components: { @@ -13,6 +17,7 @@ export default { GlLink, GlSprintf, }, + inject: ['protectedEnvironmentSettingsPath'], props: { environment: { required: true, @@ -34,9 +39,8 @@ export default { }, i18n: { header: __('Environments'), - helpMessage: __( - 'Environments allow you to track deployments of your application. %{linkStart}More information%{linkEnd}.', - ), + helpNewMessage: ENVIRONMENT_NEW_HELP_TEXT, + helpEditMessage: ENVIRONMENT_EDIT_HELP_TEXT, nameLabel: __('Name'), nameFeedback: __('This field is required'), nameDisabledHelp: __("You cannot rename an environment after it's created."), @@ -62,6 +66,9 @@ export default { isNameDisabled() { return Boolean(this.environment.id); }, + showEditHelp() { + return this.isNameDisabled && Boolean(this.protectedEnvironmentSettingsPath); + }, valid() { return { name: this.visited.name && this.environment.name !== '', @@ -89,9 +96,14 @@ export default { {{ $options.i18n.header }} </h4> <p class="gl-w-full"> - <gl-sprintf :message="$options.i18n.helpMessage"> + <gl-sprintf + :message="showEditHelp ? $options.i18n.helpEditMessage : $options.i18n.helpNewMessage" + > <template #link="{ content }"> - <gl-link :href="$options.helpPagePath">{{ content }}</gl-link> + <gl-link + :href="showEditHelp ? protectedEnvironmentSettingsPath : $options.helpPagePath" + >{{ content }}</gl-link + > </template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 55e6a891e27..b2a69cdb6c6 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -15,6 +15,7 @@ import { ENVIRONMENTS_SCOPE } from '../constants'; import EnvironmentFolder from './environment_folder.vue'; import EnableReviewAppModal from './enable_review_app_modal.vue'; import StopEnvironmentModal from './stop_environment_modal.vue'; +import StopStaleEnvironmentsModal from './stop_stale_environments_modal.vue'; import EnvironmentItem from './new_environment_item.vue'; import ConfirmRollbackModal from './confirm_rollback_modal.vue'; import DeleteEnvironmentModal from './delete_environment_modal.vue'; @@ -31,6 +32,7 @@ export default { EnableReviewAppModal, EnvironmentItem, StopEnvironmentModal, + StopStaleEnvironmentsModal, GlBadge, GlPagination, GlSearchBoxByType, @@ -75,6 +77,7 @@ export default { i18n: { newEnvironmentButtonLabel: s__('Environments|New environment'), reviewAppButtonLabel: s__('Environments|Enable review app'), + cleanUpEnvsButtonLabel: s__('Environments|Clean up environments'), available: __('Available'), stopped: __('Stopped'), prevPage: __('Go to previous page'), @@ -85,11 +88,13 @@ export default { searchPlaceholder: s__('Environments|Search by environment name'), }, modalId: 'enable-review-app-info', + stopStaleEnvsModalId: 'stop-stale-environments-modal', data() { const { page = '1', search = '', scope } = queryToObject(window.location.search); return { interval: undefined, isReviewAppModalVisible: false, + isStopStaleEnvModalVisible: false, page: parseInt(page, 10), pageInfo: {}, scope: Object.values(ENVIRONMENTS_SCOPE).includes(scope) @@ -107,6 +112,9 @@ export default { canSetupReviewApp() { return this.environmentApp?.reviewApp?.canSetupReviewApp; }, + canCleanUpEnvs() { + return this.environmentApp?.canStopStaleEnvironments; + }, folders() { return this.environmentApp?.environments?.filter((e) => e.size > 1) ?? []; }, @@ -149,6 +157,19 @@ export default { }, }; }, + openCleanUpEnvsModal() { + if (!this.canCleanUpEnvs) { + return null; + } + + return { + text: this.$options.i18n.cleanUpEnvsButtonLabel, + attributes: { + category: 'secondary', + variant: 'confirm', + }, + }; + }, stoppedCount() { return this.environmentApp?.stoppedCount; }, @@ -178,6 +199,9 @@ export default { showReviewAppModal() { this.isReviewAppModalVisible = true; }, + showCleanUpEnvsModal() { + this.isStopStaleEnvModalVisible = true; + }, setScope(scope) { this.scope = scope; this.moveToPage(1); @@ -219,16 +243,24 @@ export default { :modal-id="$options.modalId" data-testid="enable-review-app-modal" /> + <stop-stale-environments-modal + v-if="canCleanUpEnvs" + v-model="isStopStaleEnvModalVisible" + :modal-id="$options.stopStaleEnvsModalId" + data-testid="stop-stale-environments-modal" + /> <delete-environment-modal :environment="environmentToDelete" graphql /> <stop-environment-modal :environment="environmentToStop" graphql /> <confirm-rollback-modal :environment="environmentToRollback" graphql /> <canary-update-modal :environment="environmentToChangeCanary" :weight="weight" /> <gl-tabs - :action-secondary="addEnvironment" - :action-primary="openReviewAppModal" + :action-secondary="openReviewAppModal" + :action-primary="openCleanUpEnvsModal" + :action-tertiary="addEnvironment" sync-active-tab-with-query-params query-param-name="scope" - @primary="showReviewAppModal" + @secondary="showReviewAppModal" + @primary="showCleanUpEnvsModal" > <gl-tab :query-param-value="$options.ENVIRONMENTS_SCOPE.AVAILABLE" diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index 9a100e0199e..73dfd993c5b 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -323,6 +323,7 @@ export default { > <deployment :deployment="upcomingDeployment" + :visible="visible" :class="{ 'gl-ml-7': inFolder }" class="gl-pl-4" > diff --git a/app/assets/javascripts/environments/components/stop_stale_environments_modal.vue b/app/assets/javascripts/environments/components/stop_stale_environments_modal.vue new file mode 100644 index 00000000000..57873b28d37 --- /dev/null +++ b/app/assets/javascripts/environments/components/stop_stale_environments_modal.vue @@ -0,0 +1,104 @@ +<script> +import { GlTooltipDirective, GlModal, GlDatepicker, GlFormGroup } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { stopStaleEnvironments } from '~/rest_api'; +import { MIN_STALE_ENVIRONMENT_DATE, MAX_STALE_ENVIRONMENT_DATE } from '../constants'; + +export default { + id: 'stop-stale-environments-modal', + name: 'StopStaleEnvironmentsModal', + + components: { + GlModal, + GlDatepicker, + GlFormGroup, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: { + projectId: { + default: '', + }, + }, + model: { + prop: 'visible', + event: 'change', + }, + props: { + modalId: { + type: String, + required: true, + }, + visible: { + type: Boolean, + required: false, + default: false, + }, + }, + modalProps: { + primary: { + text: s__('Environments|Clean up'), + attributes: [{ variant: 'info' }], + }, + cancel: { + text: __('Cancel'), + }, + dateRange: { + minDate: MIN_STALE_ENVIRONMENT_DATE, // 10 years ago + maxDate: MAX_STALE_ENVIRONMENT_DATE, + }, + }, + + data() { + return { + stopEnvironmentsBefore: MAX_STALE_ENVIRONMENT_DATE, + }; + }, + + methods: { + onSubmit() { + stopStaleEnvironments(this.projectId, this.stopEnvironmentsBefore || this.maxDate); + }, + }, +}; +</script> + +<template> + <gl-modal + :action-primary="$options.modalProps.primary" + :action-cancel="$options.modalProps.cancel" + :visible="visible" + :modal-id="modalId" + :title="s__('Environments|Clean up environments')" + static + @primary="onSubmit" + @change="$emit('change', $event)" + > + <p> + {{ + s__( + 'Environments|Select which environments to clean up. \ + Protected environments are excluded. Learn more about cleaning up environments.', + ) + }} + </p> + + <gl-form-group + :label="s__('Environments|Stop unused environments')" + :label-description=" + s__('Environments|Stop environments that have not been updated since the specified date:') + " + label-for="stop_environments-before" + > + <gl-datepicker + v-model="stopEnvironmentsBefore" + input-id="stop-environments-before" + data-testid="stop-environments-before" + :min-date="$options.modalProps.dateRange.minDate" + :max-date="$options.modalProps.dateRange.maxDate" + :default-date="$options.modalProps.dateRange.maxDate" + /> + </gl-form-group> + </gl-modal> +</template> diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js index c4d02da9d21..28424322dd2 100644 --- a/app/assets/javascripts/environments/constants.js +++ b/app/assets/javascripts/environments/constants.js @@ -1,4 +1,5 @@ import { __, s__ } from '~/locale'; +import { getDateInPast } from '~/lib/utils/datetime_utility'; // These statuses are based on how the backend defines pod phases here // lib/gitlab/kubernetes/pod.rb @@ -77,3 +78,12 @@ export const REVIEW_APP_MODAL_I18N = { viewMoreExampleProjects: s__('EnableReviewApp|View more example projects'), copyToClipboardText: s__('EnableReviewApp|Copy snippet'), }; + +export const MIN_STALE_ENVIRONMENT_DATE = getDateInPast(new Date(), 3650); // 10 years ago +export const MAX_STALE_ENVIRONMENT_DATE = getDateInPast(new Date(), 7); // one week ago + +export const ENVIRONMENT_NEW_HELP_TEXT = __( + 'Environments allow you to track deployments of your application.%{linkStart} More information.%{linkEnd}', +); + +export const ENVIRONMENT_EDIT_HELP_TEXT = ENVIRONMENT_NEW_HELP_TEXT; diff --git a/app/assets/javascripts/environments/edit.js b/app/assets/javascripts/environments/edit.js index dd6680f64bd..a128d2fb3c7 100644 --- a/app/assets/javascripts/environments/edit.js +++ b/app/assets/javascripts/environments/edit.js @@ -7,6 +7,7 @@ export default (el) => provide: { projectEnvironmentsPath: el.dataset.projectEnvironmentsPath, updateEnvironmentPath: el.dataset.updateEnvironmentPath, + protectedEnvironmentSettingsPath: el.dataset.protectedEnvironmentSettingsPath, }, render(h) { return h(EditEnvironment, { diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue new file mode 100644 index 00000000000..77d9311743c --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue @@ -0,0 +1,31 @@ +<script> +import ActionsComponent from '~/environments/components/environment_actions.vue'; + +export default { + components: { + ActionsComponent, + }, + props: { + actions: { + // actions shape: + /* Array<{ + playable: boolean, + playPath: url, + name: string + scheduledAt: ISO_timestamp | null + }> + */ + type: Array, + required: true, + }, + }, + computed: { + isActionsShown() { + return this.actions.length > 0; + }, + }, +}; +</script> +<template> + <actions-component v-if="isActionsShown" :actions="actions" graphql /> +</template> diff --git a/app/assets/javascripts/environments/environment_details/constants.js b/app/assets/javascripts/environments/environment_details/constants.js index bf690ffedeb..3b33d6a676e 100644 --- a/app/assets/javascripts/environments/environment_details/constants.js +++ b/app/assets/javascripts/environments/environment_details/constants.js @@ -45,6 +45,12 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [ columnClass: 'gl-w-10p', tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap', }, + { + key: 'actions', + label: __('Actions'), + columnClass: 'gl-w-10p', + tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap', + }, ]; export const translations = { diff --git a/app/assets/javascripts/environments/environment_details/deployments_table.vue b/app/assets/javascripts/environments/environment_details/deployments_table.vue index 41570ee44c0..10f8c06e581 100644 --- a/app/assets/javascripts/environments/environment_details/deployments_table.vue +++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue @@ -5,11 +5,13 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import DeploymentStatusLink from './components/deployment_status_link.vue'; import DeploymentJob from './components/deployment_job.vue'; import DeploymentTriggerer from './components/deployment_triggerer.vue'; +import DeploymentActions from './components/deployment_actions.vue'; import { ENVIRONMENT_DETAILS_TABLE_FIELDS } from './constants'; export default { components: { DeploymentTriggerer, + DeploymentActions, DeploymentJob, Commit, TimeAgoTooltip, @@ -51,5 +53,8 @@ export default { <template #cell(deployed)="{ item }"> <time-ago-tooltip :time="item.deployed" /> </template> + <template #cell(actions)="{ item }"> + <deployment-actions :actions="item.actions" /> + </template> </gl-table-lite> </template> diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue index b43f4233b9c..f4657c5100a 100644 --- a/app/assets/javascripts/environments/environment_details/index.vue +++ b/app/assets/javascripts/environments/environment_details/index.vue @@ -59,7 +59,11 @@ export default { }, computed: { deployments() { - return this.project.environment?.deployments.nodes.map(convertToDeploymentTableRow) || []; + return ( + this.project.environment?.deployments.nodes.map((deployment) => + convertToDeploymentTableRow(deployment, this.project.environment), + ) || [] + ); }, isLoading() { return this.$apollo.queries.project.loading; diff --git a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql index 1a572208a1c..7a50ded7d6c 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql @@ -4,5 +4,6 @@ query getEnvironmentApp($page: Int, $scope: String, $search: String) { stoppedCount environments reviewApp + canStopStaleEnvironments } } diff --git a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql index c6c2024c840..0182b3a7234 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql @@ -13,6 +13,13 @@ query getEnvironmentDetails( environment(name: $environmentName) { id name + lastDeployment(status: SUCCESS) { + id + job { + id + name + } + } deployments( orderBy: { createdAt: DESC } first: $first @@ -36,6 +43,19 @@ query getEnvironmentDetails( name id webPath + playable + deploymentPipeline: pipeline { + id + jobs(whenExecuted: ["manual"], retried: false) { + nodes { + id + name + playable + scheduledAt + webPath + } + } + } } commit { id diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index afd56d0cf0d..e21670870b8 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -54,6 +54,7 @@ export const resolvers = (endpoint) => ({ ...convertObjectPropsToCamelCase(res.data.review_app), __typename: 'ReviewApp', }, + canStopStaleEnvironments: res.data.can_stop_stale_environments, stoppedCount: res.data.stopped_count, __typename: 'LocalEnvironmentApp', }; diff --git a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js index bfe92fe3125..9802dcbcf78 100644 --- a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js +++ b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js @@ -41,22 +41,46 @@ export const getCommitFromDeploymentNode = (deploymentNode) => { }; }; +export const convertJobToDeploymentAction = (job) => { + return { + name: job.name, + playable: job.playable, + scheduledAt: job.scheduledAt, + playPath: `${job.webPath}/play`, + }; +}; + +export const getActionsFromDeploymentNode = (deploymentNode, lastDeploymentName) => { + if (!deploymentNode || !lastDeploymentName) { + return []; + } + + return ( + deploymentNode.job?.deploymentPipeline?.jobs?.nodes + ?.filter((deployment) => deployment.name !== lastDeploymentName) + .map(convertJobToDeploymentAction) || [] + ); +}; + /** * This function transforms deploymentNode object coming from GraphQL to object compatible with app/assets/javascripts/environments/environment_details/page.vue table * @param {Object} deploymentNode * @returns {Object} */ -export const convertToDeploymentTableRow = (deploymentNode) => { +export const convertToDeploymentTableRow = (deploymentNode, environment) => { + const { lastDeployment } = environment; + const commit = getCommitFromDeploymentNode(deploymentNode); return { status: deploymentNode.status.toLowerCase(), id: deploymentNode.iid, triggerer: deploymentNode.triggerer, - commit: getCommitFromDeploymentNode(deploymentNode), + commit, job: deploymentNode.job && { webPath: deploymentNode.job.webPath, label: `${deploymentNode.job.name} (#${getIdFromGraphQLId(deploymentNode.job.id)})`, }, created: deploymentNode.createdAt || '', deployed: deploymentNode.finishedAt || '', + actions: getActionsFromDeploymentNode(deploymentNode, lastDeployment?.job?.name), }; }; diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index cebf73ef8e5..483f1d2c7a0 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -3,56 +3,11 @@ import Vue from 'vue'; import { GlAlert } from '@gitlab/ui'; import { __ } from '~/locale'; -const FLASH_TYPES = { - ALERT: 'alert', - NOTICE: 'notice', - SUCCESS: 'success', - WARNING: 'warning', -}; - -const VARIANT_SUCCESS = 'success'; -const VARIANT_WARNING = 'warning'; -const VARIANT_DANGER = 'danger'; -const VARIANT_INFO = 'info'; -const VARIANT_TIP = 'tip'; - -const FLASH_CLOSED_EVENT = 'flashClosed'; - -const getCloseEl = (flashEl) => { - return flashEl.querySelector('.js-close-icon'); -}; - -const hideFlash = (flashEl, fadeTransition = true) => { - if (fadeTransition) { - Object.assign(flashEl.style, { - transition: 'opacity 0.15s', - opacity: '0', - }); - } - - flashEl.addEventListener( - 'transitionend', - () => { - flashEl.remove(); - window.dispatchEvent(new Event('resize')); - flashEl.dispatchEvent(new Event(FLASH_CLOSED_EVENT)); - if (document.body.classList.contains('flash-shown')) - document.body.classList.remove('flash-shown'); - }, - { - once: true, - passive: true, - }, - ); - - if (!fadeTransition) flashEl.dispatchEvent(new Event('transitionend')); -}; - -const addDismissFlashClickListener = (flashEl, fadeTransition) => { - // There are some flash elements which do not have a closeEl. - // https://gitlab.com/gitlab-org/gitlab/blob/763426ef344488972eb63ea5be8744e0f8459e6b/ee/app/views/layouts/header/_read_only_banner.html.haml - getCloseEl(flashEl)?.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); -}; +export const VARIANT_SUCCESS = 'success'; +export const VARIANT_WARNING = 'warning'; +export const VARIANT_DANGER = 'danger'; +export const VARIANT_INFO = 'info'; +export const VARIANT_TIP = 'tip'; /** * Render an alert at the top of the page, or, optionally an @@ -96,7 +51,7 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => { * @param {boolean} [options.captureError] - Whether to send error to Sentry * @param {object} [options.error] - Error to be captured in Sentry */ -const createAlert = function createAlert({ +export const createAlert = ({ message, title, variant = VARIANT_DANGER, @@ -108,7 +63,7 @@ const createAlert = function createAlert({ onDismiss = null, captureError = false, error = null, -}) { +}) => { if (captureError && error) Sentry.captureException(error); const alertContainer = parent.querySelector(containerSelector); @@ -180,16 +135,3 @@ const createAlert = function createAlert({ }, }); }; - -export { - hideFlash, - addDismissFlashClickListener, - FLASH_TYPES, - FLASH_CLOSED_EVENT, - createAlert, - VARIANT_SUCCESS, - VARIANT_WARNING, - VARIANT_DANGER, - VARIANT_INFO, - VARIANT_TIP, -}; diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue index a4e883c96b5..947d3053094 100644 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -6,6 +6,7 @@ import { mapVuexModuleActions, mapVuexModuleGetters, } from '~/lib/utils/vuex_module_mappers'; +import Tracking from '~/tracking'; import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants'; import eventHub from '../event_hub'; import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils'; @@ -13,6 +14,8 @@ import FrequentItemsList from './frequent_items_list.vue'; import frequentItemsMixin from './frequent_items_mixin'; import FrequentItemsSearchInput from './frequent_items_search_input.vue'; +const trackingMixin = Tracking.mixin(); + export default { components: { FrequentItemsSearchInput, @@ -24,7 +27,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [frequentItemsMixin], + mixins: [frequentItemsMixin, trackingMixin], inject: ['vuexModule'], props: { currentUserName: { @@ -84,6 +87,13 @@ export default { 'toggleItemsListEditablity', 'fetchFrequentItems', ]), + toggleItemsListEditablityTracked() { + this.track('click_button', { + label: 'toggle_edit_frequent_items', + property: 'navigation_top', + }); + this.toggleItemsListEditablity(); + }, dropdownOpenHandler() { if (this.searchQuery === '' || isMobile()) { this.fetchFrequentItems(); @@ -155,7 +165,7 @@ export default { :title="translations.headerEditToggle" :class="{ 'gl-bg-gray-100!': isItemsListEditable }" class="gl-p-2!" - @click="toggleItemsListEditablity" + @click="toggleItemsListEditablityTracked" > <gl-icon name="pencil" :class="{ 'gl-text-gray-900!': isItemsListEditable }" /> </gl-button> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index 75ea9beb5cf..056dedf8757 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import { snakeCase } from 'lodash'; import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; @@ -61,10 +60,17 @@ export default { return highlight(this.itemName, this.matcher); }, itemTrackingLabel() { - return `${this.dropdownType}_dropdown_frequent_items_list_item_${snakeCase(this.itemName)}`; + return `${this.dropdownType}_dropdown_frequent_items_list_item`; }, }, methods: { + removeFrequentItemTracked(item) { + this.track('click_button', { + label: `${this.dropdownType}_dropdown_remove_frequent_item`, + property: 'navigation_top', + }); + this.removeFrequentItem(item); + }, ...mapVuexModuleActions((vm) => vm.vuexModule, ['removeFrequentItem']), }, }; @@ -78,7 +84,7 @@ export default { class="gl-text-left gl-w-full" button-text-classes="gl-display-flex gl-w-full" data-testid="frequent-item-link" - @click="track('click_link', { label: itemTrackingLabel })" + @click="track('click_link', { label: itemTrackingLabel, property: 'navigation_top' })" > <div class="gl-flex-grow-1"> <project-avatar @@ -116,9 +122,9 @@ export default { category="tertiary" :aria-label="__('Remove')" :title="__('Remove')" - class="gl-align-self-center gl-p-1! gl-absolute! gl-w-auto! gl-top-4 gl-right-4" + class="gl-align-self-center gl-p-1! gl-absolute! gl-w-auto! gl-right-4 gl-top-half gl-translate-y-n50" data-testid="item-remove" - @click.stop.prevent="removeFrequentItem(itemId)" + @click.stop.prevent="removeFrequentItemTracked(itemId)" > <gl-icon name="close" /> </gl-button> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue index 4a1b7e57749..023245f050b 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue @@ -28,12 +28,25 @@ export default { searchQuery: debounce(function debounceSearchQuery() { this.track('type_search_query', { label: `${this.dropdownType}_dropdown_frequent_items_search_input`, + property: 'navigation_top', }); this.setSearchQuery(this.searchQuery); }, 500), }, methods: { ...mapVuexModuleActions((vm) => vm.vuexModule, ['setSearchQuery']), + trackFocus() { + this.track('focus_input', { + label: `${this.dropdownType}_dropdown_frequent_items_search_input`, + property: 'navigation_top', + }); + }, + trackBlur() { + this.track('blur_input', { + label: `${this.dropdownType}_dropdown_frequent_items_search_input`, + property: 'navigation_top', + }); + }, }, }; </script> @@ -43,6 +56,8 @@ export default { <gl-search-box-by-type v-model="searchQuery" :placeholder="translations.searchInputPlaceholder" + @focus="trackFocus" + @blur="trackBlur" /> </div> </template> diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 2b157fac878..f4008fe3cc9 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -1,5 +1,6 @@ import autosize from 'autosize'; import $ from 'jquery'; +import { isEmpty } from 'lodash'; import GfmAutoComplete, { defaultAutocompleteConfig } from 'ee_else_ce/gfm_auto_complete'; import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; import dropzoneInput from './dropzone_input'; @@ -12,14 +13,22 @@ export default class GLForm { * @param {jQuery} form Root element of the GLForm * @param {Object} enableGFM Which autocomplete features should be enabled? * @param {Boolean} forceNew If true, treat the element as a **new** form even if `gfm-form` class already exists. + * @param {Object} gfmDataSources The paths of the autocomplete data sources to use for GfmAutoComplete + * By default, the backend embeds these in the global object gl.GfmAutocomplete.dataSources. + * Use this param to override them. */ - constructor(form, enableGFM = {}, forceNew = false) { + constructor(form, enableGFM = {}, forceNew = false, gfmDataSources = {}) { this.form = form; this.textarea = this.form.find('textarea.js-gfm-input'); this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM }; // Disable autocomplete for keywords which do not have dataSources available - const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; + let dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; + + if (!isEmpty(gfmDataSources)) { + dataSources = gfmDataSources; + } + Object.keys(this.enableGFM).forEach((item) => { if (item !== 'emojis' && !dataSources[item]) { this.enableGFM[item] = false; @@ -29,7 +38,7 @@ export default class GLForm { // Before we start, we should clean up any previous data for this form this.destroy(); // Set up the form - this.setupForm(forceNew); + this.setupForm(dataSources, forceNew); this.form.data('glForm', this); } @@ -46,7 +55,7 @@ export default class GLForm { this.form.data('glForm', null); } - setupForm(forceNew = false) { + setupForm(dataSources, forceNew = false) { const isNewForm = this.form.is(':not(.gfm-form)') || forceNew; this.form.removeClass('js-new-note-form'); if (isNewForm) { @@ -57,7 +66,7 @@ export default class GLForm { this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion'), ); - this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); + this.autoComplete = new GfmAutoComplete(dataSources); this.autoComplete.setup(this.form.find('.js-gfm-input'), this.enableGFM); this.formDropzone = dropzoneInput(this.form, { parallelUploads: 1 }); diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js index 0a4733de65f..ad339155a59 100644 --- a/app/assets/javascripts/gpg_badges.js +++ b/app/assets/javascripts/gpg_badges.js @@ -13,7 +13,7 @@ export default class GpgBadges { return Promise.resolve(); } - const badges = $('.js-loading-gpg-badge'); + const badges = $('.js-loading-signature-badge'); badges.html(loadingIconForLegacyJS()); badges.children().attr('aria-label', __('Loading')); diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 22fa2912881..3c4ca4c197e 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -1,23 +1,28 @@ export const MINIMUM_SEARCH_LENGTH = 3; -export const TYPE_BOARD = 'Board'; -export const TYPE_CI_RUNNER = 'Ci::Runner'; -export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact'; -export const TYPE_CRM_ORGANIZATION = 'CustomerRelations::Organization'; -export const TYPE_DISCUSSION = 'Discussion'; -export const TYPE_EPIC = 'Epic'; -export const TYPE_EPIC_BOARD = 'Boards::EpicBoard'; -export const TYPE_GROUP = 'Group'; -export const TYPE_ISSUE = 'Issue'; -export const TYPE_ITERATION = 'Iteration'; -export const TYPE_ITERATIONS_CADENCE = 'Iterations::Cadence'; -export const TYPE_MERGE_REQUEST = 'MergeRequest'; -export const TYPE_MILESTONE = 'Milestone'; -export const TYPE_NOTE = 'Note'; -export const TYPE_PACKAGES_PACKAGE = 'Packages::Package'; -export const TYPE_PROJECT = 'Project'; -export const TYPE_SCANNER_PROFILE = 'DastScannerProfile'; -export const TYPE_SITE_PROFILE = 'DastSiteProfile'; -export const TYPE_USER = 'User'; -export const TYPE_VULNERABILITY = 'Vulnerability'; -export const TYPE_WORK_ITEM = 'WorkItem'; +export const TYPENAME_BOARD = 'Board'; +export const TYPENAME_CI_BUILD = 'Ci::Build'; +export const TYPENAME_CI_PIPELINE = 'Ci::Pipeline'; +export const TYPENAME_CI_RUNNER = 'Ci::Runner'; +export const TYPENAME_CI_VARIABLE = 'Ci::Variable'; +export const TYPENAME_COMMIT_STATUS = 'CommitStatus'; +export const TYPENAME_CRM_CONTACT = 'CustomerRelations::Contact'; +export const TYPENAME_CRM_ORGANIZATION = 'CustomerRelations::Organization'; +export const TYPENAME_DISCUSSION = 'Discussion'; +export const TYPENAME_EPIC = 'Epic'; +export const TYPENAME_EPIC_BOARD = 'Boards::EpicBoard'; +export const TYPENAME_GROUP = 'Group'; +export const TYPENAME_ISSUE = 'Issue'; +export const TYPENAME_ITERATION = 'Iteration'; +export const TYPENAME_ITERATIONS_CADENCE = 'Iterations::Cadence'; +export const TYPENAME_MERGE_REQUEST = 'MergeRequest'; +export const TYPENAME_MILESTONE = 'Milestone'; +export const TYPENAME_NOTE = 'Note'; +export const TYPENAME_PACKAGES_PACKAGE = 'Packages::Package'; +export const TYPENAME_PROJECT = 'Project'; +export const TYPENAME_SCANNER_PROFILE = 'DastScannerProfile'; +export const TYPENAME_SITE_PROFILE = 'DastSiteProfile'; +export const TYPENAME_USER = 'User'; +export const TYPENAME_VULNERABILITIES_SCANNER = 'Vulnerabilities::Scanner'; +export const TYPENAME_VULNERABILITY = 'Vulnerability'; +export const TYPENAME_WORK_ITEM = 'WorkItem'; diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index 01cc2fc3018..316bc746051 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -6,6 +6,8 @@ import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.grap import createDefaultClient from '~/lib/graphql'; import typeDefs from '~/work_items/graphql/typedefs.graphql'; import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; +import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql'; +import { findHierarchyWidgetChildren } from '~/work_items/utils'; export const config = { typeDefs, @@ -13,7 +15,9 @@ export const config = { // included temporarily until Vuex is removed from boards app dataIdFromObject: (object) => { // eslint-disable-next-line no-underscore-dangle - return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object); + return object.__typename === 'BoardList' && !window.gon?.features?.apolloBoards + ? object.iid + : defaultDataIdFromObject(object); }, typePolicies: { Project: { @@ -72,6 +76,7 @@ export const config = { }, }; } + return incomingWidget || existingWidget; }); }, @@ -83,12 +88,85 @@ export const config = { nodes: concatPagination(), }, }, + ...(window.gon?.features?.apolloBoards + ? { + BoardList: { + fields: { + issues: { + keyArgs: ['filters'], + }, + }, + }, + IssueConnection: { + merge(existing = { nodes: [] }, incoming, { args }) { + if (!args.after) { + return incoming; + } + return { + ...incoming, + nodes: [...existing.nodes, ...incoming.nodes], + }; + }, + }, + EpicList: { + fields: { + epics: { + keyArgs: ['filters'], + }, + }, + }, + EpicConnection: { + merge(existing = { nodes: [] }, incoming, { args }) { + if (!args.after) { + return incoming; + } + return { + ...incoming, + nodes: [...existing.nodes, ...incoming.nodes], + }; + }, + }, + BoardEpicConnection: { + merge(existing = { nodes: [] }, incoming, { args }) { + if (!args.after) { + return incoming; + } + return { + ...incoming, + nodes: [...existing.nodes, ...incoming.nodes], + }; + }, + }, + } + : {}), }, }, }; export const resolvers = { Mutation: { + addHierarchyChild: (_, { id, workItem }, { cache }) => { + const queryArgs = { query: getWorkItemLinksQuery, variables: { id } }; + const sourceData = cache.readQuery(queryArgs); + + const data = produce(sourceData, (draftState) => { + findHierarchyWidgetChildren(draftState.workItem).push(workItem); + }); + + cache.writeQuery({ ...queryArgs, data }); + }, + removeHierarchyChild: (_, { id, workItem }, { cache }) => { + const queryArgs = { query: getWorkItemLinksQuery, variables: { id } }; + const sourceData = cache.readQuery(queryArgs); + + const data = produce(sourceData, (draftState) => { + const hierarchyChildren = findHierarchyWidgetChildren(draftState.workItem); + const index = hierarchyChildren.findIndex((child) => child.id === workItem.id); + hierarchyChildren.splice(index, 1); + }); + + cache.writeQuery({ ...queryArgs, data }); + }, updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => { const sourceData = cache.readQuery({ query: getIssueStateQuery }); const data = produce(sourceData, (draftData) => { diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index a622b342c0a..4a5536986bd 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -156,6 +156,7 @@ "WorkItemWidgetRequirementLegacy", "WorkItemWidgetStartAndDueDate", "WorkItemWidgetStatus", + "WorkItemWidgetTestReports", "WorkItemWidgetWeight" ] -} +}
\ No newline at end of file diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js index 8fb70eb59bd..806e89d6e9f 100644 --- a/app/assets/javascripts/graphql_shared/utils.js +++ b/app/assets/javascripts/graphql_shared/utils.js @@ -104,3 +104,15 @@ export const convertNodeIdsFromGraphQLIds = (nodes) => { return nodes.map((node) => (node.id ? { ...node, id: getIdFromGraphQLId(node.id) } : node)); }; + +/** + * This function takes a GraphQL query data as a required argument and + * the field name to resolve as an optional argument + * and returns resolved field's data or an empty array + * @param {Object} queryData + * @param {String} nodesField (in most cases it will be 'nodes') + * @returns {Array} + */ +export const getNodesOrDefault = (queryData, nodesField = 'nodes') => { + return queryData?.[nodesField] ?? []; +}; diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 46d5341ea97..148bf0a98ee 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlModal, GlEmptyState } from '@gitlab/ui'; import { createAlert } from '~/flash'; +import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status'; import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; @@ -225,7 +226,7 @@ export default { }) .catch((err) => { let message = COMMON_STR.FAILURE; - if (err.status === 403) { + if (err.status === HTTP_STATUS_FORBIDDEN) { message = COMMON_STR.LEAVE_FORBIDDEN; } createAlert({ message }); diff --git a/app/assets/javascripts/groups/init_transfer_group_form.js b/app/assets/javascripts/groups/init_transfer_group_form.js index 503dad673dd..6eab284c066 100644 --- a/app/assets/javascripts/groups/init_transfer_group_form.js +++ b/app/assets/javascripts/groups/init_transfer_group_form.js @@ -17,6 +17,7 @@ export default () => { targetFormId = null, buttonText: confirmButtonText = '', groupName = '', + groupFullPath, groupId: resourceId, isPaidGroup, } = el.dataset; @@ -35,7 +36,7 @@ export default () => { props: { isPaidGroup: parseBoolean(isPaidGroup), confirmButtonText, - confirmationPhrase: groupName, + confirmationPhrase: groupFullPath, }, on: { confirm: () => { diff --git a/app/assets/javascripts/groups_projects/components/transfer_locations.vue b/app/assets/javascripts/groups_projects/components/transfer_locations.vue index e0c8ce36e3c..360af772a10 100644 --- a/app/assets/javascripts/groups_projects/components/transfer_locations.vue +++ b/app/assets/javascripts/groups_projects/components/transfer_locations.vue @@ -25,6 +25,7 @@ export const i18n = { 'ProjectTransfer|An error occurred fetching the transfer locations, please refresh the page and try again.', ), ALERT_DISMISS_LABEL: __('Dismiss'), + NO_RESULTS_TEXT: __('No results found.'), }; export default { @@ -90,6 +91,9 @@ export default { hasGroupTransferLocations() { return this.groupTransferLocations.length; }, + hasAdditionalDropdownItems() { + return this.filteredAdditionalDropdownItems.length; + }, selectedText() { return this.value?.humanName || this.label; }, @@ -99,6 +103,17 @@ export default { showAdditionalDropdownItems() { return !this.isLoading && this.filteredAdditionalDropdownItems.length; }, + hasNoResults() { + if (this.isLoading || this.isSearchLoading) { + return false; + } + + return ( + !this.hasAdditionalDropdownItems && + !this.hasUserTransferLocations && + !this.hasGroupTransferLocations + ); + }, }, watch: { searchTerm() { @@ -274,6 +289,9 @@ export default { >{{ item.humanName }}</gl-dropdown-item > </div> + <gl-dropdown-item v-if="hasNoResults" button-class="gl-text-gray-900!" disabled>{{ + $options.i18n.NO_RESULTS_TEXT + }}</gl-dropdown-item> <gl-loading-icon v-if="isLoading" class="gl-mb-3" size="sm" /> <gl-intersection-observer v-if="hasNextPageOfGroups" @appear="handleLoadMoreGroups" /> </gl-dropdown> diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index f58781fa9ec..6c9354b663f 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -33,6 +33,13 @@ function initStatusTriggers() { if (setStatusModalTriggerEl) { setStatusModalTriggerEl.addEventListener('click', () => { + const topNavbar = document.querySelector('.navbar-gitlab'); + const buttonWithinTopNav = topNavbar && topNavbar.contains(setStatusModalTriggerEl); + Tracking.event(undefined, 'click_button', { + label: 'user_edit_status', + property: buttonWithinTopNav ? 'navigation_top' : undefined, + }); + import( /* webpackChunkName: 'statusModalBundle' */ './set_status_modal/set_status_modal_wrapper.vue' ) diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index bf5daf29b21..ace0d77c431 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -171,7 +171,7 @@ export default { Tracking.event(undefined, 'focus_input', { label: 'global_search', - property: 'top_navigation', + property: 'navigation_top', }); } }, @@ -190,7 +190,7 @@ export default { Tracking.event(undefined, 'blur_input', { label: 'global_search', - property: 'top_navigation', + property: 'navigation_top', }); }, 200); }, diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index cda3379309c..65e113e5084 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -77,3 +77,5 @@ export const DROPDOWN_ORDER = [ ]; export const FETCH_TYPES = ['generic', 'search']; + +export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px'; diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js index f6f5c6a14fa..f6963263725 100644 --- a/app/assets/javascripts/header_search/index.js +++ b/app/assets/javascripts/header_search/index.js @@ -1,36 +1,44 @@ import Vue from 'vue'; +import * as Sentry from '@sentry/browser'; import Translate from '~/vue_shared/translate'; import HeaderSearchApp from './components/app.vue'; import createStore from './store'; +import { SEARCH_INPUT_FIELD_MAX_WIDTH } from './constants'; Vue.use(Translate); export const initHeaderSearchApp = (search = '') => { const el = document.getElementById('js-header-search'); - let navBarEl = null; + const headerEl = document.querySelector('.header-content'); - if (!el) { + if (!el && !headerEl) { return false; } + const searchContainer = headerEl.querySelector('.global-search-container'); + const newHeader = headerEl.querySelector('.header-search-new'); + const { searchPath, issuesPath, mrPath, autocompletePath } = el.dataset; let { searchContext } = el.dataset; - searchContext = JSON.parse(searchContext); + + try { + searchContext = JSON.parse(searchContext); + newHeader.style.maxWidth = SEARCH_INPUT_FIELD_MAX_WIDTH; + } catch (error) { + Sentry.captureException(error); + } return new Vue({ el, store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }), - mounted() { - navBarEl = document.querySelector('.header-content'); - }, render(createElement) { return createElement(HeaderSearchApp, { on: { expandSearchBar: () => { - navBarEl?.classList.add('header-search-is-active'); + searchContainer.style.flexGrow = '1'; }, collapseSearchBar: () => { - navBarEl?.classList.remove('header-search-is-active'); + searchContainer.style.flexGrow = '0'; }, }, }); diff --git a/app/assets/javascripts/helpers/init_simple_app_helper.js b/app/assets/javascripts/helpers/init_simple_app_helper.js new file mode 100644 index 00000000000..695fc455f13 --- /dev/null +++ b/app/assets/javascripts/helpers/init_simple_app_helper.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; + +/** + * Initializes a component as a simple vue app, passing the necessary props. If the element + * has a data attribute named `data-view-model`, the content of that attributed will be + * converted from json and passed on to the component as a prop. The root component is then + * responsible for setting up it's children, injections, and other desired features. + * + * @param {string} selector css selector for where to build + * @param {Vue.component} component The Vue compoment to be built as the root of the app + * + * @example + * ```html + * <div id='#mount-here' data-view-model="{'some': 'object'}" /> + * ``` + * + * ```javascript + * initSimpleApp('#mount-here', MyApp) + * ``` + * + * This will mount MyApp as root on '#mount-here'. It will receive {'some': 'object'} as it's + * view model prop. + */ +export const initSimpleApp = (selector, component) => { + const element = document.querySelector(selector); + + if (!element) { + return null; + } + + const props = element.dataset.viewModel ? JSON.parse(element.dataset.viewModel) : {}; + + return new Vue({ + el: element, + render(h) { + return h(component, { props }); + }, + }); +}; diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index da2d4fbe7f0..8342b3f428c 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -1,10 +1,9 @@ <script> -import { mapGetters, mapState } from 'vuex'; +import { mapState } from 'vuex'; import { __ } from '~/locale'; import { rightSidebarViews, SIDEBAR_INIT_WIDTH, SIDEBAR_NAV_WIDTH } from '../../constants'; import JobsDetail from '../jobs/detail.vue'; import PipelinesList from '../pipelines/list.vue'; -import Clientside from '../preview/clientside.vue'; import ResizablePanel from '../resizable_panel.vue'; import TerminalView from '../terminal/view.vue'; import CollapsibleSidebar from './collapsible_sidebar.vue'; @@ -20,12 +19,8 @@ export default { }, computed: { ...mapState('terminal', { isTerminalVisible: 'isVisible' }), - ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']), - ...mapGetters(['packageJson']), + ...mapState(['currentMergeRequestId']), ...mapState('rightPane', ['isOpen']), - showLivePreview() { - return this.packageJson && this.clientsidePreviewEnabled; - }, rightExtensionTabs() { return [ { @@ -38,12 +33,6 @@ export default { icon: 'rocket', }, { - show: this.showLivePreview, - title: __('Live preview'), - views: [{ component: Clientside, ...rightSidebarViews.clientSidePreview }], - icon: 'live-preview', - }, - { show: this.isTerminalVisible, title: __('Terminal'), views: [{ component: TerminalView, ...rightSidebarViews.terminal }], diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue deleted file mode 100644 index 70b881b6ff6..00000000000 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ /dev/null @@ -1,191 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { listen } from 'codesandbox-api'; -import { isEmpty, debounce } from 'lodash'; -import { SandpackClient } from '@codesandbox/sandpack-client'; -import { mapActions, mapGetters, mapState } from 'vuex'; -import { - packageJsonPath, - LIVE_PREVIEW_DEBOUNCE, - PING_USAGE_PREVIEW_KEY, - PING_USAGE_PREVIEW_SUCCESS_KEY, -} from '../../constants'; -import eventHub from '../../eventhub'; -import { createPathWithExt } from '../../utils'; -import Navigator from './navigator.vue'; - -export default { - components: { - Navigator, - GlLoadingIcon, - }, - data() { - return { - client: {}, - loading: false, - sandpackReady: false, - }; - }, - computed: { - ...mapState(['entries', 'promotionSvgPath', 'links', 'codesandboxBundlerUrl']), - ...mapGetters(['packageJson', 'currentProject']), - normalizedEntries() { - return Object.keys(this.entries).reduce((acc, path) => { - const file = this.entries[path]; - - if (file.type === 'tree' || !(file.raw || file.content)) return acc; - - return { - ...acc, - [`/${path}`]: { - code: file.content || file.raw, - }, - }; - }, {}); - }, - mainEntry() { - if (!this.packageJson.raw) return false; - - const parsedPackage = JSON.parse(this.packageJson.raw); - - return parsedPackage.main; - }, - showPreview() { - return this.mainEntry && !this.loading; - }, - showEmptyState() { - return !this.mainEntry && !this.loading; - }, - showOpenInCodeSandbox() { - return this.currentProject && this.currentProject.visibility === 'public'; - }, - sandboxOpts() { - return { - files: { ...this.normalizedEntries }, - entry: `/${this.mainEntry}`, - showOpenInCodeSandbox: this.showOpenInCodeSandbox, - }; - }, - }, - watch: { - sandpackReady: { - handler(val) { - if (val) { - this.pingUsage(PING_USAGE_PREVIEW_SUCCESS_KEY); - } - }, - }, - }, - mounted() { - this.onFilesChangeCallback = debounce(() => this.update(), LIVE_PREVIEW_DEBOUNCE); - eventHub.$on('ide.files.change', this.onFilesChangeCallback); - - this.loading = true; - - return this.loadFileContent(packageJsonPath) - .then(() => { - this.loading = false; - }) - .then(() => this.$nextTick()) - .then(() => this.initPreview()); - }, - beforeDestroy() { - // Setting sandpackReady = false protects us form a phantom `update()` being called when `debounce` finishes. - this.sandpackReady = false; - eventHub.$off('ide.files.change', this.onFilesChangeCallback); - - if (!isEmpty(this.client)) { - this.client.cleanup(); - } - - this.client = {}; - - if (this.listener) { - this.listener(); - } - }, - methods: { - ...mapActions(['getFileData', 'getRawFileData']), - ...mapActions('clientside', ['pingUsage']), - loadFileContent(path) { - return this.getFileData({ path, makeFileActive: false }).then(() => - this.getRawFileData({ path }), - ); - }, - initPreview() { - if (!this.mainEntry) return null; - - this.pingUsage(PING_USAGE_PREVIEW_KEY); - - return this.loadFileContent(this.mainEntry) - .then(() => this.$nextTick()) - .then(() => { - this.initClient(); - - this.listener = listen((e) => { - switch (e.type) { - case 'done': - this.sandpackReady = true; - break; - default: - break; - } - }); - }); - }, - update() { - if (!this.sandpackReady) return; - - if (isEmpty(this.client)) { - this.initPreview(); - - return; - } - - this.client.updatePreview(this.sandboxOpts); - }, - initClient() { - const { codesandboxBundlerUrl: bundlerURL } = this; - - const settings = { - fileResolver: { - isFile: (p) => Promise.resolve(Boolean(this.entries[createPathWithExt(p)])), - readFile: (p) => this.loadFileContent(createPathWithExt(p)).then((content) => content), - }, - ...(bundlerURL ? { bundlerURL } : {}), - }; - - this.client = new SandpackClient('#ide-preview', this.sandboxOpts, settings); - }, - }, -}; -</script> - -<template> - <div class="preview h-100 w-100 d-flex flex-column gl-bg-white"> - <template v-if="showPreview"> - <navigator :client="client" /> - <div id="ide-preview"></div> - </template> - <div - v-else-if="showEmptyState" - v-once - class="d-flex h-100 flex-column align-items-center justify-content-center svg-content" - > - <img :src="promotionSvgPath" :alt="s__('IDE|Live Preview')" width="130" height="100" /> - <h3>{{ s__('IDE|Live Preview') }}</h3> - <p class="text-center"> - {{ s__('IDE|Preview your web application using Web IDE client-side evaluation.') }} - </p> - <a - :href="links.webIDEHelpPagePath" - class="btn gl-button btn-confirm" - target="_blank" - rel="noopener noreferrer" - > - {{ s__('IDE|Get started with Live Preview') }} - </a> - </div> - <gl-loading-icon v-else size="lg" class="align-self-center mt-auto mb-auto" /> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue deleted file mode 100644 index 852de16d508..00000000000 --- a/app/assets/javascripts/ide/components/preview/navigator.vue +++ /dev/null @@ -1,136 +0,0 @@ -<script> -import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import { listen } from 'codesandbox-api'; - -export default { - components: { - GlIcon, - GlLoadingIcon, - }, - props: { - client: { - type: Object, - required: true, - }, - }, - data() { - return { - currentBrowsingIndex: null, - navigationStack: [], - forwardNavigationStack: [], - path: '', - loading: true, - }; - }, - computed: { - backButtonDisabled() { - return this.navigationStack.length <= 1; - }, - forwardButtonDisabled() { - return !this.forwardNavigationStack.length; - }, - }, - mounted() { - this.listener = listen((e) => { - switch (e.type) { - case 'urlchange': - this.onUrlChange(e); - break; - case 'done': - this.loading = false; - break; - default: - break; - } - }); - }, - beforeDestroy() { - this.listener(); - }, - methods: { - onUrlChange(e) { - const lastPath = this.path; - - this.path = e.url.replace(this.client.bundlerURL, '') || '/'; - - if (lastPath !== this.path) { - this.currentBrowsingIndex = - this.currentBrowsingIndex === null ? 0 : this.currentBrowsingIndex + 1; - this.navigationStack.push(this.path); - } - }, - back() { - const lastPath = this.path; - - this.visitPath(this.navigationStack[this.currentBrowsingIndex - 1]); - - this.forwardNavigationStack.push(lastPath); - - if (this.currentBrowsingIndex === 1) { - this.currentBrowsingIndex = null; - this.navigationStack = []; - } - }, - forward() { - this.visitPath(this.forwardNavigationStack.splice(0, 1)[0]); - }, - refresh() { - this.visitPath(this.path); - }, - visitPath(path) { - // eslint-disable-next-line vue/no-mutating-props - this.client.iframe.src = `${this.client.bundlerURL}${path}`; - }, - }, -}; -</script> - -<template> - <header class="ide-preview-header d-flex align-items-center"> - <button - :aria-label="s__('IDE|Back')" - :disabled="backButtonDisabled" - :class="{ - 'disabled-content': backButtonDisabled, - }" - type="button" - class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent" - @click="back" - > - <gl-icon :size="24" name="chevron-left" class="m-auto" /> - </button> - <button - :aria-label="s__('IDE|Back')" - :disabled="forwardButtonDisabled" - :class="{ - 'disabled-content': forwardButtonDisabled, - }" - type="button" - class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent" - @click="forward" - > - <gl-icon :size="24" name="chevron-right" class="m-auto" /> - </button> - <button - :aria-label="s__('IDE|Refresh preview')" - type="button" - class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent" - @click="refresh" - > - <gl-icon :size="16" name="retry" class="m-auto" /> - </button> - <div class="position-relative w-100 gl-ml-2"> - <input - :value="path || '/'" - type="text" - class="ide-navigator-location form-control bg-white" - readonly - /> - <gl-loading-icon - v-if="loading" - size="sm" - class="position-absolute ide-preview-loading-icon" - /> - </div> - </header> -</template> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 01ce5fa07ee..1aa64656c30 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -64,7 +64,6 @@ export const rightSidebarViews = { pipelines: { name: 'pipelines-list', keepAlive: true }, jobsDetail: { name: 'jobs-detail', keepAlive: false }, mergeRequestInfo: { name: 'merge-request-info', keepAlive: true }, - clientSidePreview: { name: 'clientside', keepAlive: false }, terminal: { name: 'terminal', keepAlive: true }, }; @@ -101,22 +100,13 @@ export const commitActionTypes = { update: 'update', }; -export const packageJsonPath = 'package.json'; - export const SIDE_LEFT = 'left'; export const SIDE_RIGHT = 'right'; -// Live Preview feature -export const LIVE_PREVIEW_DEBOUNCE = 2000; - // This is the maximum number of files to auto open when opening the Web IDE // from a merge request export const MAX_MR_FILES_AUTO_OPEN = 10; export const DEFAULT_BRANCH = 'main'; -// Ping Usage Metrics Keys -export const PING_USAGE_PREVIEW_KEY = 'web_ide_clientside_preview'; -export const PING_USAGE_PREVIEW_SUCCESS_KEY = 'web_ide_clientside_preview_success'; - export const GITLAB_WEB_IDE_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/377367'; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 1347d92b3b7..29c44d2f596 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -67,10 +67,8 @@ export const initLegacyWebIDE = (el, options = {}) => { forkInfo: el.dataset.forkInfo ? JSON.parse(el.dataset.forkInfo) : null, }); this.init({ - clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled), renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode), editorTheme: window.gon?.user_color_scheme || DEFAULT_THEME, - codesandboxBundlerUrl: el.dataset.codesandboxBundlerUrl, environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance), previewMarkdownPath: el.dataset.previewMarkdownPath, userPreferencesPath: el.dataset.userPreferencesPath, diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index d3c64754e8a..4d3cefcb107 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -7,6 +7,7 @@ import csrf from '~/lib/utils/csrf'; import { getBaseConfig } from './lib/gitlab_web_ide/get_base_config'; import { setupRootElement } from './lib/gitlab_web_ide/setup_root_element'; import { GITLAB_WEB_IDE_FEEDBACK_ISSUE } from './constants'; +import { handleTracking } from './lib/gitlab_web_ide/handle_tracking_event'; const buildRemoteIdeURL = (ideRemotePath, remoteHost, remotePathArg) => { const remotePath = cleanLeadingSeparator(remotePathArg); @@ -38,6 +39,9 @@ export const initGitlabWebIDE = async (el) => { filePath, mergeRequest: mrId, forkInfo: forkInfoJSON, + editorFontSrcUrl, + editorFontFormat, + editorFontFamily, } = el.dataset; const rootEl = setupRootElement(el); @@ -64,6 +68,12 @@ export const initGitlabWebIDE = async (el) => { feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE, userPreferences: el.dataset.userPreferencesPath, }, + editorFont: { + srcUrl: editorFontSrcUrl, + fontFamily: editorFontFamily, + format: editorFontFormat, + }, + handleTracking, async handleStartRemote({ remoteHost, remotePath, connectionToken }) { const confirmed = await confirmAction( __('Are you sure you want to leave the Web IDE? All unsaved changes will be lost.'), diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index 289027c3054..7a516f5e3f5 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -1,12 +1,5 @@ -import { useNewFonts } from '~/lib/utils/common_utils'; import { getCssVariable } from '~/lib/utils/css_utils'; -const fontOptions = {}; - -if (useNewFonts()) { - fontOptions.fontFamily = getCssVariable('--code-editor-font'); -} - export const defaultEditorOptions = { model: null, readOnly: false, @@ -18,7 +11,7 @@ export const defaultEditorOptions = { wordWrap: 'on', glyphMargin: true, automaticLayout: true, - ...fontOptions, + fontFamily: getCssVariable('--code-editor-font'), }; export const defaultDiffOptions = { diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js new file mode 100644 index 00000000000..615dad02386 --- /dev/null +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js @@ -0,0 +1,20 @@ +import { snakeCase } from 'lodash'; +import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; +import Tracking from '~/tracking'; + +export const handleTracking = ({ name, data }) => { + const snakeCaseEventName = snakeCase(name); + + if (data && Object.keys(data).length) { + Tracking.event(undefined, snakeCaseEventName, { + /* See GitLab snowplow schema for a definition of the extra field + * https://docs.gitlab.com/ee/development/snowplow/schemas.html#gitlab_standard. + */ + extra: convertObjectPropsToSnakeCase(data, { + deep: true, + }), + }); + } else { + Tracking.event(undefined, snakeCaseEventName); + } +}; diff --git a/app/assets/javascripts/ide/lib/mirror.js b/app/assets/javascripts/ide/lib/mirror.js index 78990953beb..f437965b25a 100644 --- a/app/assets/javascripts/ide/lib/mirror.js +++ b/app/assets/javascripts/ide/lib/mirror.js @@ -1,3 +1,4 @@ +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { getWebSocketUrl, mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import createDiff from './create_diff'; @@ -26,7 +27,7 @@ const cancellableWait = (time) => { const isErrorResponse = (error) => error && error.code !== 0; -const isErrorPayload = (payload) => payload && payload.status_code !== 200; +const isErrorPayload = (payload) => payload && payload.status_code !== HTTP_STATUS_OK; const getErrorFromResponse = (data) => { if (isErrorResponse(data.error)) { diff --git a/app/assets/javascripts/ide/remote/index.js b/app/assets/javascripts/ide/remote/index.js index fb8db20c0c1..6966786ca4e 100644 --- a/app/assets/javascripts/ide/remote/index.js +++ b/app/assets/javascripts/ide/remote/index.js @@ -1,6 +1,7 @@ import { startRemote } from '@gitlab/web-ide'; import { getBaseConfig, setupRootElement } from '~/ide/lib/gitlab_web_ide'; import { isSameOriginUrl, joinPaths } from '~/lib/utils/url_utility'; +import { handleTracking } from '~/ide/lib/gitlab_web_ide/handle_tracking_event'; /** * @param {Element} rootEl @@ -36,5 +37,6 @@ export const mountRemoteIDE = async (el) => { // TODO Handle error better handleError: visitReturnUrl, handleClose: visitReturnUrl, + handleTracking, }); }; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index dc0f3a1d7e9..b7445d3ad0a 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -1,6 +1,7 @@ import { escape } from 'lodash'; import Vue from 'vue'; import { createAlert } from '~/flash'; +import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import { @@ -278,7 +279,7 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force = resolve(data); }) .catch((e) => { - if (e.response.status === 404) { + if (e.response.status === HTTP_STATUS_NOT_FOUND) { reject(e); } else { createAlert({ diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 3c02b1d1da7..c0f666c6652 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -2,7 +2,6 @@ import Api from '~/api'; import { addNumericSuffix } from '~/ide/utils'; import { leftSidebarViews, - packageJsonPath, DEFAULT_PERMISSIONS, PERMISSION_READ_MR, PERMISSION_CREATE_MR, @@ -153,8 +152,6 @@ export const currentBranch = (state, getters) => export const branchName = (_state, getters) => getters.currentBranch && getters.currentBranch.name; -export const packageJson = (state) => state.entries[packageJsonPath]; - export const isOnDefaultBranch = (_state, getters) => getters.currentProject && getters.currentProject.default_branch === getters.branchName; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js index b660ff178a2..c2f7126159c 100644 --- a/app/assets/javascripts/ide/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js @@ -3,7 +3,6 @@ import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; import branches from './modules/branches'; -import clientsideModule from './modules/clientside'; import commitModule from './modules/commit'; import editorModule from './modules/editor'; import { setupFileEditorsSync } from './modules/editor/setup'; @@ -29,7 +28,6 @@ export const createStoreOptions = () => ({ branches, fileTemplates: fileTemplates(), rightPane: paneModule(), - clientside: clientsideModule(), router: routerModule, editor: editorModule, }, diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js deleted file mode 100644 index 1a8e665867f..00000000000 --- a/app/assets/javascripts/ide/stores/modules/clientside/actions.js +++ /dev/null @@ -1,11 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -export const pingUsage = ({ rootGetters }, metricName) => { - const { web_url: projectUrl } = rootGetters.currentProject; - - const url = `${projectUrl}/service_ping/${metricName}`; - - return axios.post(url); -}; - -export default pingUsage; diff --git a/app/assets/javascripts/ide/stores/modules/clientside/index.js b/app/assets/javascripts/ide/stores/modules/clientside/index.js deleted file mode 100644 index b28f7b935a8..00000000000 --- a/app/assets/javascripts/ide/stores/modules/clientside/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import * as actions from './actions'; - -export default () => ({ - namespaced: true, - actions, -}); diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index b89d9d38a1a..356bbf28a48 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -26,10 +26,8 @@ export default () => ({ path: '', entry: {}, }, - clientsidePreviewEnabled: false, renderWhitespaceInCode: false, editorTheme: DEFAULT_THEME, - codesandboxBundlerUrl: null, environmentsGuidanceAlertDismissed: false, environmentsGuidanceAlertDetected: false, previewMarkdownPath: '', diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index db677c574d1..6dc0b2cec24 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -65,7 +65,9 @@ const STATUS_MAP = { }; function isIncompleteImport(stats) { - return Object.keys(stats.fetched).some((key) => stats.fetched[key] !== stats.imported[key]); + return Object.keys(stats?.fetched ?? []).some( + (key) => stats.fetched[key] !== stats.imported[key], + ); } export default { @@ -91,7 +93,9 @@ export default { computed: { knownStats() { const knownStatisticKeys = Object.keys(STATISTIC_ITEMS); - return Object.keys(this.stats.fetched).filter((key) => knownStatisticKeys.includes(key)); + return Object.keys(this.stats?.fetched ?? []).filter((key) => + knownStatisticKeys.includes(key), + ); }, hasStats() { @@ -142,7 +146,13 @@ export default { <template> <div> <div class="gl-display-inline-block gl-w-13"> - <gl-badge :icon="mappedStatus.icon" :variant="mappedStatus.variant" size="md" class="gl-mr-2"> + <gl-badge + :icon="mappedStatus.icon" + :variant="mappedStatus.variant" + size="md" + icon-size="sm" + class="gl-mr-2" + > {{ mappedStatus.text }} </gl-badge> </div> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue index 8d72942447c..ed7c9e7abe9 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue @@ -46,7 +46,7 @@ export default { <template> <span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center"> <gl-dropdown - v-if="isProjectsImportEnabled && isAvailableForImport" + v-if="isProjectsImportEnabled && (isAvailableForImport || isFinished)" :text="isFinished ? __('Re-import with projects') : __('Import with projects')" :disabled="isInvalid" variant="confirm" @@ -60,7 +60,7 @@ export default { }}</gl-dropdown-item> </gl-dropdown> <gl-button - v-else-if="isAvailableForImport" + v-else-if="isAvailableForImport || isFinished" :disabled="isInvalid" variant="confirm" category="secondary" @@ -70,7 +70,7 @@ export default { {{ isFinished ? __('Re-import') : __('Import') }} </gl-button> <gl-icon - v-if="isAvailableForImport && isFinished" + v-if="isFinished" v-gl-tooltip :size="16" name="information-o" 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 c590d832568..7d2ddd2176b 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 @@ -17,6 +17,7 @@ import { import { debounce } from 'lodash'; import { createAlert } from '~/flash'; import { s__, __, n__, sprintf } from '~/locale'; +import { HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; @@ -102,8 +103,12 @@ export default { perPage: DEFAULT_PAGE_SIZE, selectedGroupsIds: [], pendingGroupsIds: [], + reimportRequests: [], importTargets: {}, unavailableFeaturesAlertVisible: true, + helpUrl: helpPagePath('ee/user/group/import', { + anchor: 'visibility-rules', + }), }; }, @@ -177,9 +182,14 @@ export default { const importTarget = this.importTargets[group.id]; const status = this.getStatus(group); + const isGroupAvailableForImport = isFinished(group) + ? this.reimportRequests.includes(group.id) + : isAvailableForImport(group) && status !== STATUSES.SCHEDULING; + const flags = { - isInvalid: importTarget.validationErrors?.length > 0, - isAvailableForImport: isAvailableForImport(group) && status !== STATUSES.SCHEDULING, + isInvalid: (importTarget.validationErrors ?? []).filter((e) => !e.nonBlocking).length > 0, + isAvailableForImport: isGroupAvailableForImport, + isAllowedForReimport: false, isFinished: isFinished(group), }; @@ -355,13 +365,9 @@ export default { this.validateImportTarget(newImportTarget); }, - async importGroups(importRequests) { + async requestGroupsImport(importRequests) { const newPendingGroupsIds = importRequests.map((request) => request.sourceGroupId); newPendingGroupsIds.forEach((id) => { - this.importTargets[id].validationErrors = [ - { field: NEW_NAME_FIELD, message: i18n.ERROR_IMPORT_COMPLETED }, - ]; - if (!this.pendingGroupsIds.includes(id)) { this.pendingGroupsIds.push(id); } @@ -373,11 +379,19 @@ export default { variables: { importRequests }, }); } catch (error) { - createAlert({ - message: i18n.ERROR_IMPORT, - captureError: true, - error, - }); + if (error.networkError?.response?.status === HTTP_STATUS_TOO_MANY_REQUESTS) { + newPendingGroupsIds.forEach((id) => { + this.importTargets[id].validationErrors = [ + { field: NEW_NAME_FIELD, message: i18n.ERROR_TOO_MANY_REQUESTS, nonBlocking: true }, + ]; + }); + } else { + createAlert({ + message: i18n.ERROR_IMPORT, + captureError: true, + error, + }); + } } finally { this.pendingGroupsIds = this.pendingGroupsIds.filter( (id) => !newPendingGroupsIds.includes(id), @@ -385,6 +399,26 @@ export default { } }, + importGroup({ group, extraArgs, index }) { + if (group.flags.isFinished && !this.reimportRequests.includes(group.id)) { + this.validateImportTarget(group.importTarget); + this.reimportRequests.push(group.id); + this.$nextTick(() => { + this.$refs[`importTargetCell-${index}`].focusNewName(); + }); + } else { + this.reimportRequests = this.reimportRequests.filter((id) => id !== group.id); + this.requestGroupsImport([ + { + sourceGroupId: group.id, + targetNamespace: group.importTarget.targetNamespace.fullPath, + newName: group.importTarget.newName, + ...extraArgs, + }, + ]); + } + }, + importSelectedGroups(extraArgs = {}) { const importRequests = this.groupsTableData .filter((group) => this.selectedGroupsIds.includes(group.id)) @@ -395,7 +429,7 @@ export default { ...extraArgs, })); - this.importGroups(importRequests); + this.requestGroupsImport(importRequests); }, setPageSize(size) { @@ -552,6 +586,7 @@ export default { </div> <gl-alert v-if="unavailableFeatures.length > 0 && unavailableFeaturesAlertVisible" + data-testid="unavailable-features-alert" variant="warning" :title="unavailableFeaturesAlertTitle" @dismiss="unavailableFeaturesAlertVisible = false" @@ -582,6 +617,19 @@ export default { </template> </gl-sprintf> </gl-alert> + <gl-alert variant="warning" :dismissible="false" class="mt-3"> + <gl-sprintf + :message=" + s__( + 'BulkImport|Be aware of %{linkStart}visibility rules%{linkEnd} when importing groups.', + ) + " + > + <template #link="{ content }"> + <gl-link :href="helpUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> <div class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex" > @@ -742,8 +790,9 @@ export default { <template #cell(webUrl)="{ item: group }"> <import-source-cell :group="group" /> </template> - <template #cell(importTarget)="{ item: group }"> + <template #cell(importTarget)="{ item: group, index }"> <import-target-cell + :ref="`importTargetCell-${index}`" :group="group" :group-path-regex="groupPathRegex" @update-target-namespace="updateImportTarget(group, { targetNamespace: $event })" @@ -753,22 +802,13 @@ export default { <template #cell(progress)="{ item: group }"> <import-status-cell :status="group.visibleStatus" class="gl-line-height-32" /> </template> - <template #cell(actions)="{ item: group }"> + <template #cell(actions)="{ item: group, index }"> <import-actions-cell :is-projects-import-enabled="isProjectsImportEnabled" :is-finished="group.flags.isFinished" :is-available-for-import="group.flags.isAvailableForImport" :is-invalid="group.flags.isInvalid" - @import-group=" - importGroups([ - { - sourceGroupId: group.id, - targetNamespace: group.importTarget.targetNamespace.fullPath, - newName: group.importTarget.newName, - ...$event, - }, - ]) - " + @import-group="importGroup({ group, extraArgs: $event, index })" /> </template> </gl-table> 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 04a90d9c20c..807b084fefb 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 @@ -38,6 +38,15 @@ export default { // this will highlight field in green like "passed validation" return this.group.flags.isInvalid && this.group.flags.isAvailableForImport ? false : null; }, + isPathSelectionAvailable() { + return this.group.flags.isAvailableForImport; + }, + }, + + methods: { + focusNewName() { + this.$refs.newName.$el.focus(); + }, }, }; </script> @@ -48,7 +57,7 @@ export default { <import-group-dropdown #default="{ namespaces }" :text="fullPath" - :disabled="!group.flags.isAvailableForImport" + :disabled="!isPathSelectionAvailable" 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" @@ -76,23 +85,22 @@ export default { <div class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10" :class="{ - 'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport, - 'gl-border-gray-200': group.flags.isAvailableForImport, + 'gl-text-gray-400 gl-border-gray-100': !isPathSelectionAvailable, + 'gl-border-gray-200': isPathSelectionAvailable, }" > / </div> <div class="gl-flex-grow-1"> <gl-form-input + ref="newName" class="gl-rounded-top-left-none gl-rounded-bottom-left-none" :class="{ - 'gl-inset-border-1-gray-200!': - group.flags.isAvailableForImport && !group.flags.isInvalid, - 'gl-inset-border-1-gray-100!': - !group.flags.isAvailableForImport && !group.flags.isInvalid, + 'gl-inset-border-1-gray-200!': isPathSelectionAvailable, + 'gl-inset-border-1-gray-100!': !isPathSelectionAvailable, }" debounce="500" - :disabled="!group.flags.isAvailableForImport" + :disabled="!isPathSelectionAvailable" :value="group.importTarget.newName" :aria-label="__('New name')" :state="validNameState" @@ -101,7 +109,7 @@ export default { </div> </div> <div - v-if="group.flags.isAvailableForImport && (group.flags.isInvalid || validationMessage)" + v-if="isPathSelectionAvailable && (group.flags.isInvalid || validationMessage)" class="gl-text-red-500 gl-m-0 gl-mt-2" role="alert" > diff --git a/app/assets/javascripts/import_entities/import_groups/constants.js b/app/assets/javascripts/import_entities/import_groups/constants.js index 7e532dfec05..60938272d11 100644 --- a/app/assets/javascripts/import_entities/import_groups/constants.js +++ b/app/assets/javascripts/import_entities/import_groups/constants.js @@ -11,6 +11,9 @@ export const i18n = { ), ERROR_IMPORT: s__('BulkImport|Importing the group failed.'), ERROR_IMPORT_COMPLETED: s__('BulkImport|Import is finished. Pick another name for re-import'), + ERROR_TOO_MANY_REQUESTS: s__( + 'Bulkmport|Over six imports in one minute were attempted. Wait at least one minute and try again.', + ), NO_GROUPS_FOUND: s__('BulkImport|No groups found'), OWNER: __('Owner'), 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 63a36f1a79f..aaa37f145aa 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 @@ -182,16 +182,16 @@ export default { <div v-if="repositories.length" class="gl-w-full"> <table> <thead class="gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100"> - <th class="import-jobs-from-col gl-p-4 gl-vertical-align-top gl-border-b-1"> + <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1"> {{ fromHeaderText }} </th> - <th class="import-jobs-to-col gl-p-4 gl-vertical-align-top gl-border-b-1"> + <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1"> {{ __('To GitLab') }} </th> - <th class="import-jobs-status-col gl-p-4 gl-vertical-align-top gl-border-b-1"> + <th class="gl-p-4 gl-vertical-align-top gl-border-b-1"> {{ __('Status') }} </th> - <th class="import-jobs-cta-col gl-p-4 gl-vertical-align-top gl-border-b-1"></th> + <th class="gl-p-4 gl-vertical-align-top gl-border-b-1"></th> </thead> <tbody> <template v-for="repo in repositories"> diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue index da5dcfa71e3..265cca9070e 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue @@ -9,6 +9,8 @@ import { GlDropdownDivider, GlDropdownSectionHeader, GlTooltip, + GlSprintf, + GlTooltipDirective, } from '@gitlab/ui'; import { mapState, mapGetters, mapActions } from 'vuex'; import { __ } from '~/locale'; @@ -32,6 +34,10 @@ export default { GlBadge, GlLink, GlTooltip, + GlSprintf, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { repo: { @@ -53,6 +59,12 @@ export default { }, }, + data() { + return { + isSelectedForReimport: false, + }; + }, + computed: { ...mapState(['ciCdOnly']), ...mapGetters(['getImportTarget']), @@ -94,7 +106,11 @@ export default { }, importButtonText() { - return this.ciCdOnly ? __('Connect') : __('Import'); + if (this.ciCdOnly) { + return __('Connect'); + } + + return this.isFinished ? __('Re-import') : __('Import'); }, newNameInput: { @@ -115,6 +131,22 @@ export default { importTarget: { ...this.importTarget, ...changedValues }, }); }, + + handleImportRepo() { + if (this.isFinished && !this.isSelectedForReimport) { + this.isSelectedForReimport = true; + this.$nextTick(() => { + this.$refs.newNameInput.$el.focus(); + }); + } else { + this.isSelectedForReimport = false; + + this.fetchImport({ + repoId: this.repo.importSource.id, + optionalStages: this.optionalStages, + }); + } + }, }, helpUrl: helpPagePath('/user/project/import/github.md'), @@ -132,6 +164,20 @@ export default { >{{ repo.importSource.fullName }} <gl-icon v-if="repo.importSource.providerLink" name="external-link" /> </gl-link> + <div v-if="isFinished" class="gl-font-sm"> + <gl-sprintf :message="s__('BulkImport|Last imported to %{link}')"> + <template #link> + <gl-link + :href="repo.importedProject.fullPath" + class="gl-font-sm" + target="_blank" + data-qa-selector="go_to_project_link" + > + {{ displayFullPath }} + </gl-link> + </template> + </gl-sprintf> + </div> </td> <td class="gl-display-flex gl-sm-flex-wrap gl-p-4 gl-pt-5 gl-vertical-align-top" @@ -139,7 +185,7 @@ export default { data-qa-selector="project_path_content" > <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template> - <template v-else-if="isImportNotStarted"> + <template v-else-if="isImportNotStarted || isSelectedForReimport"> <div class="gl-display-flex gl-align-items-stretch gl-w-full"> <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace"> <template v-if="namespaces.length"> @@ -166,6 +212,7 @@ export default { / </div> <gl-form-input + ref="newNameInput" v-model="newNameInput" class="gl-rounded-top-left-none gl-rounded-bottom-left-none" data-qa-selector="project_path_field" @@ -177,7 +224,7 @@ export default { <td class="gl-p-4 gl-vertical-align-top" data-qa-selector="import_status_indicator"> <import-status :status="importStatus" :stats="stats" /> </td> - <td data-testid="actions" class="gl-vertical-align-top gl-pt-4"> + <td data-testid="actions" class="gl-vertical-align-top gl-pt-4 gl-white-space-nowrap"> <gl-tooltip :target="() => $refs.cancelButton.$el"> <div class="gl-text-left"> <p class="gl-mb-5 gl-font-weight-bold">{{ s__('ImportProjects|Cancel import') }}</p> @@ -199,22 +246,26 @@ export default { @click="cancelImport({ repoId: repo.importSource.id })" /> <gl-button - v-if="isFinished" - class="btn btn-default" - :href="repo.importedProject.fullPath" - rel="noreferrer noopener" - target="_blank" - data-qa-selector="go_to_project_button" - >{{ __('Go to project') }} - </gl-button> - <gl-button - v-if="isImportNotStarted" + v-if="isImportNotStarted || isFinished" type="button" data-qa-selector="import_button" - @click="fetchImport({ repoId: repo.importSource.id, optionalStages })" + @click="handleImportRepo()" > {{ importButtonText }} </gl-button> + <gl-icon + v-if="isFinished" + v-gl-tooltip + :size="16" + name="information-o" + :title=" + s__( + 'ImportProjects|Re-import creates a new project. It does not sync with the existing project.', + ) + " + class="gl-ml-3" + /> + <gl-badge v-else-if="isIncompatible" variant="danger">{{ __('Incompatible project') }}</gl-badge> diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js index 8b2e0364d7a..734e7b10a77 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js +++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js @@ -2,16 +2,6 @@ import Vue from 'vue'; import { STATUSES } from '../../constants'; import * as types from './mutation_types'; -const makeNewImportedProject = (importedProject) => ({ - importSource: { - id: importedProject.id, - fullName: importedProject.importSource, - sanitizedName: importedProject.name, - providerLink: importedProject.providerLink, - }, - importedProject: { ...importedProject }, -}); - const makeNewIncompatibleProject = (project) => ({ importSource: { ...project, incompatible: true }, importedProject: null, @@ -55,14 +45,6 @@ export default { // Legacy code path, will be removed when all importers will be switched to new pagination format // https://gitlab.com/gitlab-org/gitlab/-/issues/27370#note_379034091 - const newImportedProjects = processLegacyEntries({ - newRepositories: repositories.importedProjects.filter( - (p) => p.importStatus !== STATUSES.CANCELED, - ), - existingRepositories: state.repositories, - factory: makeNewImportedProject, - }); - const incompatibleRepos = repositories.incompatibleRepos ?? []; const newIncompatibleProjects = processLegacyEntries({ newRepositories: incompatibleRepos, @@ -70,16 +52,22 @@ export default { factory: makeNewIncompatibleProject, }); - const existingProjects = [...newImportedProjects, ...state.repositories]; - const existingProjectNames = new Set(existingProjects.map((p) => p.importSource.fullName)); + const existingProjectNames = new Set(state.repositories.map((p) => p.importSource.fullName)); + const importedProjects = [...(repositories.importedProjects ?? [])].reverse(); const newProjects = repositories.providerRepos .filter((project) => !existingProjectNames.has(project.fullName)) - .map((project) => ({ - importSource: project, - importedProject: null, - })); + .map((project) => { + const importedProject = importedProjects.find( + (p) => p.providerLink === project.providerLink, + ); + + return { + importSource: project, + importedProject, + }; + }); - state.repositories = [...existingProjects, ...newProjects, ...newIncompatibleProjects]; + state.repositories = [...state.repositories, ...newProjects, ...newIncompatibleProjects]; if (incompatibleRepos.length === 0 && repositories.providerRepos.length === 0) { state.pageInfo.page -= 1; @@ -113,7 +101,7 @@ export default { [types.RECEIVE_IMPORT_ERROR](state, repoId) { const existingRepo = state.repositories.find((r) => r.importSource.id === repoId); - existingRepo.importedProject = null; + existingRepo.importedProject.importStatus = STATUSES.FAILED; }, [types.RECEIVE_JOBS_SUCCESS](state, updatedProjects) { diff --git a/app/assets/javascripts/import_entities/import_projects/utils.js b/app/assets/javascripts/import_entities/import_projects/utils.js index c4c9e544c1e..08a96160ee3 100644 --- a/app/assets/javascripts/import_entities/import_projects/utils.js +++ b/app/assets/javascripts/import_entities/import_projects/utils.js @@ -11,7 +11,7 @@ export function getImportStatus(project) { export function isProjectImportable(project) { return ( !isIncompatible(project) && - [STATUSES.NONE, STATUSES.CANCELED].includes(getImportStatus(project)) + [STATUSES.NONE, STATUSES.CANCELED, STATUSES.FAILED].includes(getImportStatus(project)) ); } diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 14ab7b2dc1e..f8e70fea7aa 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -402,6 +402,7 @@ export default { > <gl-link data-testid="incident-link" + data-qa-selector="incident_link" :href="showIncidentLink(item)" class="gl-min-w-0" > diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index b956bdf067d..5d08520bb5c 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -58,19 +58,21 @@ export const integrationTriggerEvents = { export const integrationTriggerEventTitles = { [integrationTriggerEvents.PUSH]: s__('IntegrationEvents|A push is made to the repository'), [integrationTriggerEvents.ISSUE]: s__( - 'IntegrationEvents|An issue is created, updated, or closed', + 'IntegrationEvents|An issue is created, closed, or reopened', ), [integrationTriggerEvents.CONFIDENTIAL_ISSUE]: s__( - 'IntegrationEvents|A confidential issue is created, updated, or closed', + 'IntegrationEvents|A confidential issue is created, closed, or reopened', ), [integrationTriggerEvents.MERGE_REQUEST]: s__( - 'IntegrationEvents|A merge request is created, updated, or merged', + 'IntegrationEvents|A merge request is created, merged, closed, or reopened', ), - [integrationTriggerEvents.NOTE]: s__('IntegrationEvents|A comment is added on an issue'), + [integrationTriggerEvents.NOTE]: s__('IntegrationEvents|A comment is added'), [integrationTriggerEvents.CONFIDENTIAL_NOTE]: s__( - 'IntegrationEvents|A comment is added on a confidential issue', + 'IntegrationEvents|An internal note or comment on a confidential issue is added', + ), + [integrationTriggerEvents.TAG_PUSH]: s__( + 'IntegrationEvents|A tag is pushed to the repository or removed', ), - [integrationTriggerEvents.TAG_PUSH]: s__('IntegrationEvents|A tag is pushed to the repository'), [integrationTriggerEvents.PIPELINE]: s__('IntegrationEvents|A pipeline status changes'), [integrationTriggerEvents.WIKI_PAGE]: s__('IntegrationEvents|A wiki page is created or updated'), [integrationTriggerEvents.DEPLOYMENT]: s__( @@ -88,7 +90,7 @@ export const billingPlanNames = { [billingPlans.ULTIMATE]: s__('BillingPlans|Ultimate'), }; -const INTEGRATION_TYPE_SLACK = 'slack'; +export const INTEGRATION_TYPE_SLACK = 'slack'; const INTEGRATION_TYPE_SLACK_APPLICATION = 'gitlab_slack_application'; const INTEGRATION_TYPE_MATTERMOST = 'mattermost'; diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 1e58b604bf7..d671ec33bcb 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -208,6 +208,17 @@ export default { data-testid="redirect-to-field" /> + <div v-if="shouldUpgradeSlack" class="gl-mb-6"> + <gl-alert + :dismissible="false" + :title="$options.slackUpgradeInfo.title" + :primary-button-link="customState.upgradeSlackUrl" + :primary-button-text="$options.slackUpgradeInfo.btnText" + class="gl-mb-5" + >{{ $options.slackUpgradeInfo.text }}</gl-alert + > + </div> + <override-dropdown v-if="defaultState !== null" :inherit-from-id="defaultState.id" @@ -241,17 +252,6 @@ export default { </div> </section> - <div v-if="shouldUpgradeSlack" class="gl-border-t"> - <gl-alert - :dismissible="false" - :title="$options.slackUpgradeInfo.title" - :primary-button-link="customState.upgradeSlackUrl" - :primary-button-text="$options.slackUpgradeInfo.btnText" - class="gl-mb-8 gl-mt-5" - >{{ $options.slackUpgradeInfo.text }}</gl-alert - > - </div> - <template v-if="hasSections"> <integration-form-section v-for="(section, index) in customState.sections" diff --git a/app/assets/javascripts/integrations/index/components/integrations_table.vue b/app/assets/javascripts/integrations/index/components/integrations_table.vue index 439c243f418..62f0fe4d6bf 100644 --- a/app/assets/javascripts/integrations/index/components/integrations_table.vue +++ b/app/assets/javascripts/integrations/index/components/integrations_table.vue @@ -1,7 +1,9 @@ <script> import { GlIcon, GlLink, GlTable, GlTooltipDirective } from '@gitlab/ui'; +import { INTEGRATION_TYPE_SLACK } from '~/integrations/constants'; import { sprintf, s__, __ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -13,6 +15,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { integrations: { type: Array, @@ -55,6 +58,15 @@ export default { }, ]; }, + filteredIntegrations() { + if (this.glFeatures.integrationSlackAppNotifications) { + return this.integrations.filter( + (integration) => + !(integration.name === INTEGRATION_TYPE_SLACK && integration.active === false), + ); + } + return this.integrations; + }, }, methods: { getStatusTooltipTitle(integration) { @@ -67,7 +79,7 @@ export default { </script> <template> - <gl-table :items="integrations" :fields="fields" :empty-text="emptyText" show-empty fixed> + <gl-table :items="filteredIntegrations" :fields="fields" :empty-text="emptyText" show-empty fixed> <template #cell(active)="{ item }"> <gl-icon v-if="item.active" 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 fa1aa6b0d88..607c888b85a 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -1,8 +1,7 @@ <script> import { GlAlert, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlLink, GlSprintf, GlFormCheckboxGroup, @@ -13,6 +12,7 @@ import { import { partition, isString, uniqueId, isEmpty } from 'lodash'; import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue'; import Api from '~/api'; +import Tracking from '~/tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { getParameterValues } from '~/lib/utils/url_utility'; @@ -22,6 +22,7 @@ import { INVITE_MEMBERS_FOR_TASK, MEMBER_MODAL_LABELS, LEARN_GITLAB, + INVITE_MEMBER_MODAL_TRACKING_CATEGORY, } from '../constants'; import eventHub from '../event_hub'; import { responseFromSuccess } from '../utils/response_message_parser'; @@ -40,8 +41,7 @@ export default { components: { GlAlert, GlLink, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlSprintf, GlFormCheckboxGroup, GlButton, @@ -52,6 +52,7 @@ export default { ModalConfetti, UserLimitNotification, }, + mixins: [Tracking.mixin({ category: INVITE_MEMBER_MODAL_TRACKING_CATEGORY })], inject: ['newProjectPath'], props: { id: { @@ -109,6 +110,11 @@ export default { required: false, default: () => ({}), }, + activeTrialDataset: { + type: Object, + required: false, + default: () => ({}), + }, reloadPageOnSubmit: { type: Boolean, required: false, @@ -124,6 +130,7 @@ export default { invalidMembers: {}, selectedTasksToBeDone: [], selectedTaskProject: this.projects[0], + selectedTaskProjectId: this.projects[0]?.id, source: 'unknown', mode: 'default', // Kept in sync with "base" @@ -131,6 +138,7 @@ export default { errorsLimit: 2, isErrorsSectionExpanded: false, shouldShowEmptyInvitesAlert: false, + projectsForDropdown: this.projects.map((p) => ({ value: p.id, text: p.title, ...p })), }; }, computed: { @@ -263,11 +271,12 @@ export default { usersToAddById.map((user) => user.id).join(','), ]; }, - openModal({ mode = 'default', source }) { + openModal({ mode = 'default', source = 'unknown' }) { this.mode = mode; this.source = source; this.$root.$emit(BV_SHOW_MODAL, this.modalId); + this.track('render', { label: this.source }); }, closeModal() { this.$root.$emit(BV_HIDE_MODAL, this.modalId); @@ -339,6 +348,12 @@ export default { const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property }); tracking.event(INVITE_MEMBERS_FOR_TASK.submit); }, + onCancel() { + this.track('click_cancel', { label: this.source }); + }, + onClose() { + this.track('click_x', { label: this.source }); + }, resetFields() { this.clearValidation(); this.isLoading = false; @@ -347,10 +362,12 @@ export default { this.selectedTasksToBeDone = []; [this.selectedTaskProject] = this.projects; }, - changeSelectedTaskProject(project) { - this.selectedTaskProject = project; + changeSelectedTaskProject(projectId) { + this.selectedTaskProject = this.projects.find((project) => project.id === projectId); }, onInviteSuccess() { + this.track('invite_successful', { label: this.source }); + if (this.reloadPageOnSubmit) { reloadOnInvitationSuccess(); } else { @@ -404,7 +421,10 @@ export default { :new-users-to-invite="newUsersToInvite" :root-group-id="rootId" :users-limit-dataset="usersLimitDataset" + :active-trial-dataset="activeTrialDataset" :full-path="fullPath" + @close="onClose" + @cancel="onCancel" @reset="resetFields" @submit="sendInvite" @access-level="onAccessLevelUpdate" @@ -513,23 +533,14 @@ export default { <label class="gl-mt-5 gl-display-block"> {{ $options.labels.tasksProject.title }} </label> - <gl-dropdown + <gl-collapsible-listbox + v-model="selectedTaskProjectId" + :items="projectsForDropdown" + :block="true" class="gl-w-half gl-xs-w-full" - :text="selectedTaskProject.title" data-testid="invite-members-modal-project-select" - > - <template v-for="project in projects"> - <gl-dropdown-item - :key="project.id" - active-class="is-active" - is-check-item - :is-checked="project.id === selectedTaskProject.id" - @click="changeSelectedTaskProject(project)" - > - {{ project.title }} - </gl-dropdown-item> - </template> - </gl-dropdown> + @select="changeSelectedTaskProject" + /> </template> </template> <gl-alert 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 2cbd681c67d..1e3b6093f0b 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -206,7 +206,7 @@ export default { this.track('render', { category: 'default', label: ON_SHOW_TRACK_LABEL }); } }, - onCloseModal(e) { + onCancel(e) { if (this.preventCancelDefault) { e.preventDefault(); } else { @@ -225,6 +225,9 @@ export default { expiresAt: this.selectedDate, }); }, + onClose() { + this.$emit('close'); + }, }, HEADER_CLOSE_LABEL, ACCESS_EXPIRE_DATE, @@ -249,7 +252,8 @@ export default { :action-cancel="actionCancel" @shown="onShowModal" @primary="onSubmit" - @cancel="onCloseModal" + @cancel="onCancel" + @close="onClose" @hidden="onReset" > <content-transition @@ -267,11 +271,12 @@ export default { <strong>{{ content }}</strong> </template> </gl-sprintf> + <slot name="intro-text-after"></slot> </p> - <slot name="intro-text-after"></slot> </div> <slot name="alert"></slot> + <slot name="active-trial-alert"></slot> <gl-form-group :label="labelSearchField" diff --git a/app/assets/javascripts/invite_members/components/project_select.vue b/app/assets/javascripts/invite_members/components/project_select.vue index b7a3918813b..c1114c240b9 100644 --- a/app/assets/javascripts/invite_members/components/project_select.vue +++ b/app/assets/javascripts/invite_members/components/project_select.vue @@ -1,28 +1,23 @@ <script> -import { - GlAvatarLabeled, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, -} from '@gitlab/ui'; +import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui'; import { debounce } from 'lodash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { getProjects } from '~/rest_api'; import { SEARCH_DELAY, GROUP_FILTERS } from '../constants'; +// We can have GlCollapsibleListbox dropdown panel with full +// width once we implement +// https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2133 +// https://gitlab.com/gitlab-org/gitlab/-/issues/390411 export default { name: 'ProjectSelect', components: { GlAvatarLabeled, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, + GlCollapsibleListbox, }, model: { - prop: 'selectedProject', + prop: 'selectedProjectId', }, props: { groupsFilter: { @@ -41,18 +36,21 @@ export default { return { isFetching: false, projects: [], - selectedProject: {}, + selectedProjectId: '', searchTerm: '', errorMessage: '', }; }, computed: { selectedProjectName() { - return this.selectedProject.name || this.$options.i18n.dropdownText; + return this.selectedProject.nameWithNamespace || this.$options.i18n.dropdownText; }, isFetchResultEmpty() { return this.projects.length === 0 && !this.isFetching; }, + selectedProject() { + return this.projects.find((prj) => prj.id === this.selectedProjectId) || {}; + }, }, watch: { searchTerm() { @@ -70,10 +68,14 @@ export default { .then((response) => { this.projects = response.data.map((project) => ({ ...convertObjectPropsToCamelCase(project), - name: project.name_with_namespace, + text: project.name_with_namespace, + value: project.id, })); }) .catch(() => { + // To be displayed in GlCollapsibleListbox once we implement + // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2132 + // https://gitlab.com/gitlab-org/gitlab/-/issues/389974 this.errorMessage = this.$options.i18n.errorFetchingProjects; }) .finally(() => { @@ -83,9 +85,7 @@ export default { fetchProjects() { return getProjects(this.searchTerm, this.$options.defaultFetchOptions); }, - selectProject(project) { - this.selectedProject = project; - + selectProject() { this.$emit('input', this.selectedProject); }, }, @@ -104,40 +104,28 @@ export default { }; </script> <template> - <div> - <gl-dropdown - data-testid="project-select-dropdown" - :text="selectedProjectName" - toggle-class="gl-mb-2" - block - menu-class="gl-w-full!" - > - <gl-search-box-by-type - v-model="searchTerm" - :is-loading="isFetching" - :placeholder="$options.i18n.searchPlaceholder" - data-qa-selector="project_select_dropdown_search_field" + <gl-collapsible-listbox + v-model="selectedProjectId" + searchable + :items="projects" + :searching="isFetching" + :toggle-text="selectedProjectName" + :search-placeholder="$options.i18n.searchPlaceholder" + :no-results-text="$options.i18n.emptySearchResult" + data-testid="project-select-dropdown" + data-qa-selector="project_select_dropdown" + class="gl-collapsible-listbox-w-full" + @search="searchTerm = $event" + @select="selectProject" + > + <template #list-item="{ item }"> + <gl-avatar-labeled + :label="item.text" + :src="item.avatarUrl" + :entity-id="item.id" + :entity-name="item.name" + :size="32" /> - <gl-dropdown-item - v-for="project in projects" - :key="project.id" - :name="project.name" - @click="selectProject(project)" - > - <gl-avatar-labeled - :label="project.name" - :src="project.avatarUrl" - :entity-id="project.id" - :entity-name="project.name" - :size="32" - /> - </gl-dropdown-item> - <gl-dropdown-text v-if="errorMessage" data-testid="error-message"> - <span class="gl-text-gray-500">{{ errorMessage }}</span> - </gl-dropdown-text> - <gl-dropdown-text v-else-if="isFetchResultEmpty" data-testid="empty-result-message"> - <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> - </gl-dropdown-text> - </gl-dropdown> - </div> + </template> + </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index edc0ebff083..ac0b708c55e 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -20,6 +20,7 @@ export const USERS_FILTER_ALL = 'all'; export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id'; export const TRIGGER_ELEMENT_BUTTON = 'button'; export const TRIGGER_ELEMENT_SIDE_NAV = 'side-nav'; +export const INVITE_MEMBER_MODAL_TRACKING_CATEGORY = 'invite_members_modal'; export const TRIGGER_DEFAULT_QA_SELECTOR = 'invite_members_button'; export const MEMBERS_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite members'); export const MEMBERS_MODAL_CELEBRATE_TITLE = s__( @@ -138,6 +139,7 @@ export const GROUP_MODAL_LABELS = { export const LEARN_GITLAB = 'learn_gitlab'; export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed'; +export const ON_CELEBRATION_TRACK_LABEL = 'invite_celebration_modal'; export const INFO_ALERT_TITLE = s__( 'InviteMembersModal|Your top-level group %{namespaceName} is over the %{dashboardLimit} user limit.', 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 842ab07f368..4f539cd8756 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -41,6 +41,9 @@ export default (function initInviteMembersModal() { usersLimitDataset: convertObjectPropsToCamelCase( JSON.parse(el.dataset.usersLimitDataset || '{}'), ), + activeTrialDataset: convertObjectPropsToCamelCase( + JSON.parse(el.dataset.activeTrialDataset || '{}'), + ), reloadPageOnSubmit: parseBoolean(el.dataset.reloadPageOnSubmit), }, }), diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue index fcebae3af71..71ec5544c34 100644 --- a/app/assets/javascripts/issuable/components/issuable_by_email.vue +++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue @@ -9,6 +9,7 @@ import { GlFormInputGroup, GlIcon, } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import axios from '~/lib/utils/axios_utils'; import { sprintf, __ } from '~/locale'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; @@ -36,7 +37,7 @@ export default { default: null, }, issuableType: { - default: 'issue', + default: TYPE_ISSUE, }, emailsHelpPagePath: { default: '', @@ -54,7 +55,7 @@ export default { data() { return { email: this.initialEmail, - issuableName: this.issuableType === 'issue' ? __('issue') : __('merge request'), + issuableName: this.issuableType === TYPE_ISSUE ? __('issue') : __('merge request'), }; }, computed: { diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue index 14325d6b64e..0e58f3793bc 100644 --- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue +++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue @@ -2,7 +2,7 @@ import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { sprintf, __ } from '~/locale'; -import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { TYPE_ISSUE, WorkspaceType } from '~/issues/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; @@ -12,8 +12,8 @@ const NoteableTypeText = { }; export default { + TYPE_ISSUE, WorkspaceType, - IssuableType, components: { GlIcon, ConfidentialityBadge, @@ -61,7 +61,7 @@ export default { v-if="isConfidential" data-testid="confidential" :workspace-type="$options.WorkspaceType.project" - :issuable-type="$options.IssuableType.Issue" + :issuable-type="$options.TYPE_ISSUE" /> <template v-for="meta in warningIconsMeta"> <div diff --git a/app/assets/javascripts/issuable/components/issue_milestone.vue b/app/assets/javascripts/issuable/components/issue_milestone.vue index 4f1001e8c3b..c7da3e59098 100644 --- a/app/assets/javascripts/issuable/components/issue_milestone.vue +++ b/app/assets/javascripts/issuable/components/issue_milestone.vue @@ -82,7 +82,7 @@ export default { <span v-if="milestoneStart || milestoneDue" :class="{ - 'text-danger-muted': isMilestonePastDue, + 'gl-text-red-300': isMilestonePastDue, 'text-tertiary': !isMilestonePastDue, }" ><span>{{ milestoneDatesHuman }}</span diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index c815c7aaba9..608c1deac64 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -3,7 +3,7 @@ import '~/commons/bootstrap'; import { GlIcon, GlLink, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; -import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { isMetaKey } from '~/lib/utils/common_utils'; import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; @@ -69,7 +69,7 @@ export default { return `${this.iconClass} ic-${this.iconName}`; }, workItemId() { - return convertToGraphQLId(TYPE_WORK_ITEM, this.idKey); + return convertToGraphQLId(TYPENAME_WORK_ITEM, this.idKey); }, }, methods: { diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue index 6c4ffc44444..0c75e44443d 100644 --- a/app/assets/javascripts/issuable/components/status_box.vue +++ b/app/assets/javascripts/issuable/components/status_box.vue @@ -4,7 +4,7 @@ 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 { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { IssuableStates } from '~/vue_shared/issuable/list/constants'; export const badgeState = Vue.observable({ @@ -92,7 +92,7 @@ export default { return STATUS[this.state]; }, badgeIcon() { - if (this.issuableType === IssuableType.Issue) { + if (this.issuableType === TYPE_ISSUE) { return ISSUE_ICONS[this.state]; } return MERGE_REQUEST_ICONS[this.state]; diff --git a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js index c386267501a..201782a201a 100644 --- a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js @@ -43,10 +43,10 @@ export default { */ getFormDataAsObject() { + const assigneeIds = this.form.find('input[name="update[assignee_ids][]"]').val(); const formData = { update: { state_event: this.form.find('input[name="update[state_event]"]').val(), - assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()], milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), @@ -57,6 +57,9 @@ export default { remove_label_ids: [], }, }; + if (assigneeIds) { + formData.update.assignee_ids = [assigneeIds]; + } if (this.willUpdateLabels) { formData.update.add_label_ids = this.$labelDropdown.data('user-checked'); formData.update.remove_label_ids = this.$labelDropdown.data('user-unchecked'); diff --git a/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js index 095da60a583..9c891bcfc9e 100644 --- a/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js @@ -1,9 +1,9 @@ /* eslint-disable class-methods-use-this, no-new */ - import $ from 'jquery'; import issuableEventHub from '~/issues/list/eventhub'; import LabelsSelect from '~/labels/labels_select'; import { + mountAssigneesDropdown, mountMilestoneDropdown, mountMoveIssuesButton, mountStatusDropdown, @@ -64,6 +64,7 @@ export default class IssuableBulkUpdateSidebar { mountMoveIssuesButton(); mountStatusDropdown(); mountSubscriptionsDropdown(); + mountAssigneesDropdown(); // Checking IS_EE and using ee_else_ce is odd, but we do it here to satisfy // the import/no-unresolved lint rule when FOSS_ONLY=1, even though at diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index 99a3f76ca76..8a094d5d688 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -60,8 +60,6 @@ export default class IssuableForm { return; } this.form = form; - this.toggleWip = this.toggleWip.bind(this); - this.renderWipExplanation = this.renderWipExplanation.bind(this); this.resetAutosave = this.resetAutosave.bind(this); this.handleSubmit = this.handleSubmit.bind(this); // prettier-ignore @@ -86,6 +84,7 @@ export default class IssuableForm { this.fallbackKey = getFallbackKey(); this.titleField = this.form.find('input[name*="[title]"]'); this.descriptionField = this.form.find('textarea[name*="[description]"]'); + this.draftCheck = document.querySelector('input.js-toggle-draft'); if (!(this.titleField.length && this.descriptionField.length)) { return; } @@ -93,8 +92,7 @@ export default class IssuableForm { this.autosaves = this.initAutosave(); this.form.on('submit', this.handleSubmit); this.form.on('click', '.btn-cancel, .js-reset-autosave', this.resetAutosave); - this.form.find('.js-unwrap-on-load').unwrap(); - this.initWip(); + this.initDraft(); const $issuableDueDate = $('#issuable-due-date'); @@ -160,48 +158,34 @@ export default class IssuableForm { }); } - initWip() { - this.$wipExplanation = this.form.find('.js-wip-explanation'); - this.$noWipExplanation = this.form.find('.js-no-wip-explanation'); - if (!(this.$wipExplanation.length && this.$noWipExplanation.length)) { - return undefined; + initDraft() { + if (this.draftCheck) { + this.draftCheck.addEventListener('click', () => this.writeDraftStatus()); + this.titleField.on('keyup blur', () => this.readDraftStatus()); + + this.readDraftStatus(); } - this.form.on('click', '.js-toggle-wip', this.toggleWip); - this.titleField.on('keyup blur', this.renderWipExplanation); - return this.renderWipExplanation(); } - workInProgress() { + isMarkedDraft() { return this.draftRegex.test(this.titleField.val()); } - - renderWipExplanation() { - if (this.workInProgress()) { - // These strings are not "translatable" (the code is hard-coded to look for them) - this.$wipExplanation.find('code')[0].textContent = - 'Draft'; /* eslint-disable-line @gitlab/require-i18n-strings */ - this.$wipExplanation.show(); - return this.$noWipExplanation.hide(); - } - this.$wipExplanation.hide(); - return this.$noWipExplanation.show(); + readDraftStatus() { + this.draftCheck.checked = this.isMarkedDraft(); } - - toggleWip(event) { - event.preventDefault(); - if (this.workInProgress()) { - this.removeWip(); + writeDraftStatus() { + if (this.draftCheck.checked) { + this.addDraft(); } else { - this.addWip(); + this.removeDraft(); } - return this.renderWipExplanation(); } - removeWip() { + removeDraft() { return this.titleField.val(this.titleField.val().replace(this.draftRegex, '')); } - addWip() { + addDraft() { this.titleField.val(`Draft: ${this.titleField.val()}`); } } diff --git a/app/assets/javascripts/issuable/popover/components/issue_popover.vue b/app/assets/javascripts/issuable/popover/components/issue_popover.vue index 945a3782642..55fb3958e82 100644 --- a/app/assets/javascripts/issuable/popover/components/issue_popover.vue +++ b/app/assets/javascripts/issuable/popover/components/issue_popover.vue @@ -4,7 +4,7 @@ import query from 'ee_else_ce/issuable/popover/queries/issue.query.graphql'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import IssueMilestone from '~/issuable/components/issue_milestone.vue'; import StatusBox from '~/issuable/components/status_box.vue'; -import { IssuableStatus } from '~/issues/constants'; +import { STATUS_CLOSED } from '~/issues/constants'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; @@ -57,7 +57,7 @@ export default { return Object.keys(this.issue).length > 0; }, isIssueClosed() { - return this.issue?.state === IssuableStatus.Closed; + return this.issue?.state === STATUS_CLOSED; }, }, apollo: { diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js index 4b9a42da178..ba05dd731f7 100644 --- a/app/assets/javascripts/issues/constants.js +++ b/app/assets/javascripts/issues/constants.js @@ -1,22 +1,27 @@ import { __ } from '~/locale'; -export const IssuableStatus = { - Closed: 'closed', - Open: 'opened', - Reopened: 'reopened', -}; +export const STATUS_CLOSED = 'closed'; +export const STATUS_OPEN = 'opened'; +export const STATUS_REOPENED = 'reopened'; + +export const TITLE_LENGTH_MAX = 255; + +export const TYPE_EPIC = 'epic'; +export const TYPE_ISSUE = 'issue'; export const IssuableStatusText = { - [IssuableStatus.Closed]: __('Closed'), - [IssuableStatus.Open]: __('Open'), - [IssuableStatus.Reopened]: __('Open'), + [STATUS_CLOSED]: __('Closed'), + [STATUS_OPEN]: __('Open'), + [STATUS_REOPENED]: __('Open'), }; +// Deprecated - use individual constants instead like `TYPE_ISSUE` above export const IssuableType = { Issue: 'issue', Epic: 'epic', MergeRequest: 'merge_request', Alert: 'alert', + TestCase: 'test_case', }; export const IssueType = { diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue index 8edc9a08c9e..a4a2feba716 100644 --- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -1,12 +1,14 @@ <script> -import { GlButton, GlEmptyState, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlFilteredSearchToken, GlTooltipDirective } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import getIssuesQuery from 'ee_else_ce/issues/dashboard/queries/get_issues.query.graphql'; import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; -import { IssuableStatus } from '~/issues/constants'; +import { STATUS_CLOSED } from '~/issues/constants'; import { CREATED_DESC, + defaultTypeTokenOptions, + i18n, PAGE_SIZE, PARAM_STATE, UPDATED_DESC, @@ -26,21 +28,29 @@ import { import axios from '~/lib/utils/axios_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { getParameterByName } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; import { + OPERATORS_IS, + OPERATORS_IS_NOT_OR, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, + TOKEN_TITLE_CONFIDENTIAL, TOKEN_TITLE_LABEL, TOKEN_TITLE_MILESTONE, TOKEN_TITLE_MY_REACTION, + TOKEN_TITLE_SEARCH_WITHIN, + TOKEN_TITLE_TYPE, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, TOKEN_TYPE_LABEL, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_SEARCH_WITHIN, + TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; +import getIssuesCountsQuery from '../queries/get_issues_counts.query.graphql'; import { AutocompleteCache } from '../utils'; const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); @@ -52,17 +62,7 @@ const MilestoneToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); export default { - i18n: { - calendarButtonText: __('Subscribe to calendar'), - closed: __('CLOSED'), - closedMoved: __('CLOSED (MOVED)'), - emptyStateWithFilterTitle: __('Sorry, your filter produced no results'), - emptyStateWithFilterDescription: __('To widen your search, change or remove filters above'), - emptyStateWithoutFilterTitle: __('Please select at least one filter to see results'), - errorFetchingIssues: __('An error occurred while loading issues'), - rssButtonText: __('Subscribe to RSS feed'), - searchInputPlaceholder: __('Search or filter results...'), - }, + i18n, IssuableListTabs, components: { GlButton, @@ -105,6 +105,7 @@ export default { return { filterTokens: getFilterTokens(window.location.search), issues: [], + issuesCounts: {}, issuesError: null, pageInfo: {}, pageParams: getInitialPageParams(), @@ -116,15 +117,7 @@ export default { issues: { query: getIssuesQuery, variables() { - return { - hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn, - isSignedIn: this.isSignedIn, - search: this.searchQuery, - sort: this.sortKey, - state: this.state, - ...this.pageParams, - ...this.apiFilterParams, - }; + return this.queryVariables; }, update(data) { return data.issues.nodes ?? []; @@ -141,13 +134,33 @@ export default { }, debounce: 200, }, + issuesCounts: { + query: getIssuesCountsQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data ?? {}; + }, + error(error) { + this.issuesError = this.$options.i18n.errorFetchingCounts; + Sentry.captureException(error); + }, + skip() { + return !this.hasSearch; + }, + debounce: 200, + context: { + isSingleRequest: true, + }, + }, }, computed: { apiFilterParams() { return convertToApiParams(this.filterTokens); }, emptyStateDescription() { - return this.hasSearch ? this.$options.i18n.emptyStateWithFilterDescription : undefined; + return this.hasSearch ? this.$options.i18n.noSearchResultsDescription : undefined; }, emptyStateSvgPath() { return this.hasSearch @@ -156,12 +169,23 @@ export default { }, emptyStateTitle() { return this.hasSearch - ? this.$options.i18n.emptyStateWithFilterTitle - : this.$options.i18n.emptyStateWithoutFilterTitle; + ? this.$options.i18n.noSearchResultsTitle + : this.$options.i18n.noSearchNoFilterTitle; }, hasSearch() { return Boolean(this.searchQuery || Object.keys(this.urlFilterParams).length); }, + queryVariables() { + return { + hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn, + isSignedIn: this.isSignedIn, + search: this.searchQuery, + sort: this.sortKey, + state: this.state, + ...this.pageParams, + ...this.apiFilterParams, + }; + }, renderedIssues() { return this.hasSearch ? this.issues : []; }, @@ -186,6 +210,7 @@ export default { title: TOKEN_TITLE_ASSIGNEE, icon: 'user', token: UserToken, + operators: OPERATORS_IS_NOT_OR, fetchUsers: this.fetchUsers, preloadedUsers, recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-assignee', @@ -195,6 +220,7 @@ export default { title: TOKEN_TITLE_AUTHOR, icon: 'pencil', token: UserToken, + operators: OPERATORS_IS_NOT_OR, fetchUsers: this.fetchUsers, defaultUsers: [], preloadedUsers, @@ -205,6 +231,7 @@ export default { title: TOKEN_TITLE_LABEL, icon: 'labels', token: LabelToken, + operators: OPERATORS_IS_NOT_OR, fetchLabels: this.fetchLabels, recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-label', }, @@ -217,10 +244,46 @@ export default { recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-milestone', shouldSkipSort: true, }, + { + type: TOKEN_TYPE_SEARCH_WITHIN, + title: TOKEN_TITLE_SEARCH_WITHIN, + icon: 'search', + token: GlFilteredSearchToken, + unique: true, + operators: OPERATORS_IS, + options: [ + { icon: 'title', value: 'TITLE', title: this.$options.i18n.titles }, + { + icon: 'text-description', + value: 'DESCRIPTION', + title: this.$options.i18n.descriptions, + }, + ], + }, + { + type: TOKEN_TYPE_TYPE, + title: TOKEN_TITLE_TYPE, + icon: 'issues', + token: GlFilteredSearchToken, + options: defaultTypeTokenOptions, + }, ]; if (this.isSignedIn) { tokens.push({ + type: TOKEN_TYPE_CONFIDENTIAL, + title: TOKEN_TITLE_CONFIDENTIAL, + icon: 'eye-slash', + token: GlFilteredSearchToken, + unique: true, + operators: OPERATORS_IS, + options: [ + { icon: 'eye-slash', value: 'yes', title: this.$options.i18n.confidentialYes }, + { icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo }, + ], + }); + + tokens.push({ type: TOKEN_TYPE_MY_REACTION, title: TOKEN_TITLE_MY_REACTION, icon: 'thumb-up', @@ -248,6 +311,14 @@ export default { hasIssueWeightsFeature: this.hasIssueWeightsFeature, }); }, + tabCounts() { + const { openedIssues, closedIssues, allIssues } = this.issuesCounts; + return { + [IssuableStates.Opened]: openedIssues?.count, + [IssuableStates.Closed]: closedIssues?.count, + [IssuableStates.All]: allIssues?.count, + }; + }, urlFilterParams() { return convertToUrlParams(this.filterTokens); }, @@ -292,10 +363,10 @@ export default { return axios.get('/-/autocomplete/users.json', { params: { active: true, search } }); }, getStatus(issue) { - if (issue.state === IssuableStatus.Closed && issue.moved) { + if (issue.state === STATUS_CLOSED && issue.moved) { return this.$options.i18n.closedMoved; } - if (issue.state === IssuableStatus.Closed) { + if (issue.state === STATUS_CLOSED) { return this.$options.i18n.closed; } return undefined; @@ -372,12 +443,14 @@ export default { :issuables-loading="$apollo.queries.issues.loading" namespace="dashboard" recent-searches-storage-key="issues" - :search-input-placeholder="$options.i18n.searchInputPlaceholder" + :search-input-placeholder="$options.i18n.searchPlaceholder" :search-tokens="searchTokens" :show-pagination-controls="showPaginationControls" show-work-item-type-icon :sort-options="sortOptions" + :tab-counts="tabCounts" :tabs="$options.IssuableListTabs" + truncate-counts :url-params="urlParams" use-keyset-pagination @click-tab="handleClickTab" @@ -389,10 +462,10 @@ export default { > <template #nav-actions> <gl-button :href="rssPath" icon="rss"> - {{ $options.i18n.rssButtonText }} + {{ $options.i18n.rssLabel }} </gl-button> <gl-button :href="calendarPath" icon="calendar"> - {{ $options.i18n.calendarButtonText }} + {{ $options.i18n.calendarLabel }} </gl-button> </template> diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql index 43b8804108c..5625e6afad3 100644 --- a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" -#import "~/issues/list/queries/issue.fragment.graphql" +#import "./issue.fragment.graphql" query getDashboardIssues( $hideUsers: Boolean = false @@ -10,11 +10,15 @@ query getDashboardIssues( $assigneeId: String $assigneeUsernames: [String!] $authorUsername: String + $confidential: Boolean $labelName: [String] $milestoneTitle: [String] $milestoneWildcardId: MilestoneWildcardId $myReactionEmoji: String + $types: [IssueType!] + $in: [IssuableSearchableField!] $not: NegatedIssueFilterInput + $or: UnionedIssueFilterInput $afterCursor: String $beforeCursor: String $firstPageSize: Int @@ -27,11 +31,15 @@ query getDashboardIssues( assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername + confidential: $confidential labelName: $labelName milestoneTitle: $milestoneTitle milestoneWildcardId: $milestoneWildcardId myReactionEmoji: $myReactionEmoji + types: $types + in: $in not: $not + or: $or after: $afterCursor before: $beforeCursor first: $firstPageSize diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql new file mode 100644 index 00000000000..b36f546e4ab --- /dev/null +++ b/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql @@ -0,0 +1,70 @@ +query getDashboardIssuesCount( + $search: String + $assigneeId: String + $assigneeUsernames: [String!] + $authorUsername: String + $confidential: Boolean + $labelName: [String] + $milestoneTitle: [String] + $milestoneWildcardId: MilestoneWildcardId + $myReactionEmoji: String + $types: [IssueType!] + $in: [IssuableSearchableField!] + $not: NegatedIssueFilterInput + $or: UnionedIssueFilterInput +) { + openedIssues: issues( + state: opened + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + in: $in + not: $not + or: $or + ) { + count + } + closedIssues: issues( + state: closed + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + in: $in + not: $not + or: $or + ) { + count + } + allIssues: issues( + state: all + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + in: $in + not: $not + or: $or + ) { + count + } +} diff --git a/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql b/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql new file mode 100644 index 00000000000..040763f2ba4 --- /dev/null +++ b/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql @@ -0,0 +1,56 @@ +fragment IssueFragment on Issue { + id + iid + confidential + createdAt + downvotes + dueDate + hidden + humanTimeEstimate + mergeRequestsCount + moved + state + title + updatedAt + closedAt + upvotes + userDiscussionsCount @include(if: $isSignedIn) + webPath + webUrl + type + assignees @skip(if: $hideUsers) { + nodes { + id + avatarUrl + name + username + webUrl + } + } + author @skip(if: $hideUsers) { + id + avatarUrl + name + username + webUrl + } + labels { + nodes { + id + color + title + description + } + } + milestone { + id + dueDate + startDate + webPath + title + } + taskCompletionStatus { + completedCount + count + } +} diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index e3716d0e111..5b5f1d273d0 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -60,7 +60,7 @@ export function initShow() { const { issueType, ...issuableData } = parseIssuableData(el); if (issueType === IssueType.Incident) { - initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId }); + initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId }, store); initHeaderActions(store, IssueType.Incident); initLinkedResources(); initRelatedIssues(IssueType.Incident); diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue index 5a37751410a..652d4e0fb42 100644 --- a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue +++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue @@ -2,8 +2,9 @@ import { GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; +import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue'; import { i18n } from '../constants'; -import NewIssueDropdown from './new_issue_dropdown.vue'; +import { hasNewIssueDropdown } from '../has_new_issue_dropdown_mixin'; export default { i18n, @@ -14,8 +15,9 @@ export default { GlEmptyState, GlLink, GlSprintf, - NewIssueDropdown, + NewResourceDropdown, }, + mixins: [hasNewIssueDropdown()], inject: [ 'canCreateProjects', 'emptyStateSvgPath', @@ -75,7 +77,13 @@ export default { :export-csv-path="exportCsvPathWithQuery" :issuable-count="currentTabCount" /> - <new-issue-dropdown v-if="showNewIssueDropdown" class="gl-align-self-center" /> + <new-resource-dropdown + v-if="showNewIssueDropdown" + class="gl-align-self-center" + :query="$options.searchProjectsQuery" + :query-variables="newIssueDropdownQueryVariables" + :extract-projects="extractProjects" + /> </template> </gl-empty-state> <hr /> diff --git a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue index 1139861ae78..d11540ad3dd 100644 --- a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue @@ -1,6 +1,6 @@ <script> import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { IssuableStatus } from '~/issues/constants'; +import { STATUS_CLOSED } from '~/issues/constants'; import { dateInWords, getTimeRemainingInWords, @@ -43,8 +43,7 @@ export default { }, showDueDateInRed() { return ( - isInPast(newDateAsLocaleTime(this.issue.dueDate)) && - this.issue.state !== IssuableStatus.Closed + isInPast(newDateAsLocaleTime(this.issue.dueDate)) && this.issue.state !== STATUS_CLOSED ); }, timeEstimate() { 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 e4000184f41..6c46013e4f9 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -7,18 +7,18 @@ import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time 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 { createAlert, VARIANT_INFO } from '~/flash'; -import { TYPE_USER } from '~/graphql_shared/constants'; +import { TYPENAME_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { ITEM_TYPE } from '~/groups/constants'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; -import { IssuableStatus } from '~/issues/constants'; +import { STATUS_CLOSED } from '~/issues/constants'; import axios from '~/lib/utils/axios_utils'; +import { fetchPolicies } from '~/lib/graphql'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; import { - FILTERED_SEARCH_TERM, OPERATORS_IS, OPERATORS_IS_NOT, OPERATORS_IS_NOT_OR, @@ -48,6 +48,7 @@ import { import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue'; import { CREATED_DESC, defaultTypeTokenOptions, @@ -82,9 +83,9 @@ import { getSortOptions, isSortKey, } from '../utils'; +import { hasNewIssueDropdown } from '../has_new_issue_dropdown_mixin'; import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue'; import EmptyStateWithoutAnyIssues from './empty_state_without_any_issues.vue'; -import NewIssueDropdown from './new_issue_dropdown.vue'; const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); const EmojiToken = () => @@ -112,12 +113,12 @@ export default { IssuableList, IssueCardStatistics, IssueCardTimeInfo, - NewIssueDropdown, + NewResourceDropdown, }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], + mixins: [glFeatureFlagMixin(), hasNewIssueDropdown()], inject: [ 'autocompleteAwardEmojisPath', 'calendarPath', @@ -134,7 +135,6 @@ export default { 'hasScopedLabelsFeature', 'initialEmail', 'initialSort', - 'isAnonymousSearchDisabled', 'isIssueRepositioningDisabled', 'isProject', 'isPublicVisibilityRestricted', @@ -190,8 +190,15 @@ export default { update(data) { return data[this.namespace]?.issues.nodes ?? []; }, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + // We need this for handling loading state when using frontend cache + // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106004#note_1217325202 for details + notifyOnNetworkStatusChange: true, result({ data }) { - this.pageInfo = data?.[this.namespace]?.issues.pageInfo ?? {}; + if (!data) { + return; + } + this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {}; this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); }, error(error) { @@ -293,7 +300,7 @@ export default { if (gon.current_user_id) { preloadedUsers.push({ - id: convertToGraphQLId(TYPE_USER, gon.current_user_id), + id: convertToGraphQLId(TYPENAME_USER, gon.current_user_id), name: gon.current_user_fullname, username: gon.current_username, avatar_url: gon.current_user_avatar_url, @@ -354,6 +361,7 @@ export default { token: LabelToken, operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, fetchLabels: this.fetchLabels, + fetchLatestLabels: this.glFeatures.frontendCaching ? this.fetchLatestLabels : null, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`, }, { @@ -473,8 +481,16 @@ export default { page_before: this.pageParams.beforeCursor ?? undefined, }; }, - shouldDisableTextSearch() { - return this.isAnonymousSearchDisabled && !this.isSignedIn; + // due to the issues with cache-and-network, we need this hack to check if there is any data for the query in the cache. + // if we have cached data, we disregard the loading state + isLoading() { + return ( + this.$apollo.queries.issues.loading && + !this.$apollo.provider.clients.defaultClient.readQuery({ + query: getIssuesQuery, + variables: this.queryVariables, + }) + ); }, }, watch: { @@ -514,11 +530,12 @@ export default { fetchReleases(search) { return this.fetchWithCache(this.releasesPath, 'releases', 'tag', search); }, - fetchLabels(search) { + fetchLabelsWithFetchPolicy(search, fetchPolicy = fetchPolicies.CACHE_FIRST) { return this.$apollo .query({ query: searchLabelsQuery, variables: { fullPath: this.fullPath, search, isProject: this.isProject }, + fetchPolicy, }) .then(({ data }) => data[this.namespace]?.labels.nodes) .then((labels) => @@ -527,6 +544,12 @@ export default { labels.filter((label) => label.title.toLowerCase().includes(search.toLowerCase())), ); }, + fetchLabels(search) { + return this.fetchLabelsWithFetchPolicy(search); + }, + fetchLatestLabels(search) { + return this.fetchLabelsWithFetchPolicy(search, fetchPolicies.NETWORK_ONLY); + }, fetchMilestones(search) { return this.$apollo .query({ @@ -549,10 +572,10 @@ export default { return `${this.exportCsvPath}${window.location.search}`; }, getStatus(issue) { - if (issue.state === IssuableStatus.Closed && issue.moved) { + if (issue.state === STATUS_CLOSED && issue.moved) { return this.$options.i18n.closedMoved; } - if (issue.state === IssuableStatus.Closed) { + if (issue.state === STATUS_CLOSED) { return this.$options.i18n.closed; } return undefined; @@ -569,9 +592,6 @@ export default { const bulkUpdateSidebar = await import('~/issuable'); bulkUpdateSidebar.initBulkUpdateSidebar('issuable_'); - const UsersSelect = (await import('~/users_select')).default; - new UsersSelect(); // eslint-disable-line no-new - this.hasInitBulkEdit = true; } @@ -591,7 +611,7 @@ export default { this.issuesError = null; }, handleFilter(tokens) { - this.setFilterTokens(tokens); + this.filterTokens = tokens; this.pageParams = getInitialPageParams(this.pageSize); this.$router.push({ query: this.urlParams }); @@ -684,24 +704,6 @@ export default { Sentry.captureException(error); }); }, - setFilterTokens(tokens) { - this.filterTokens = this.removeDisabledSearchTerms(tokens); - - if (this.filterTokens.length < tokens.length) { - this.showAnonymousSearchingMessage(); - } - }, - removeDisabledSearchTerms(filters) { - return this.shouldDisableTextSearch - ? filters.filter((token) => !(token.type === FILTERED_SEARCH_TERM && token.value?.data)) - : filters; - }, - showAnonymousSearchingMessage() { - createAlert({ - message: this.$options.i18n.anonymousSearchingMessage, - variant: VARIANT_INFO, - }); - }, showIssueRepositioningMessage() { createAlert({ message: this.$options.i18n.issueRepositioningMessage, @@ -737,7 +739,7 @@ export default { sortKey = defaultSortKey; } - this.setFilterTokens(getFilterTokens(window.location.search)); + this.filterTokens = getFilterTokens(window.location.search); this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); this.pageParams = getInitialPageParams( @@ -773,7 +775,7 @@ export default { :current-tab="state" :tab-counts="tabCounts" :truncate-counts="!isProject" - :issuables-loading="$apollo.queries.issues.loading" + :issuables-loading="isLoading" :is-manual-ordering="isManualOrdering" :show-bulk-edit-sidebar="showBulkEditSidebar" :show-pagination-controls="showPaginationControls" @@ -831,7 +833,12 @@ export default { {{ $options.i18n.newIssueLabel }} </gl-button> <slot name="new-objective-button"></slot> - <new-issue-dropdown v-if="showNewIssueDropdown" /> + <new-resource-dropdown + v-if="showNewIssueDropdown" + :query="$options.searchProjectsQuery" + :query-variables="newIssueDropdownQueryVariables" + :extract-projects="extractProjects" + /> </template> <template #timeframe="{ issuable = {} }"> diff --git a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue deleted file mode 100644 index e420c21a11f..00000000000 --- a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue +++ /dev/null @@ -1,127 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlLoadingIcon, - GlSearchBoxByType, -} from '@gitlab/ui'; -import { createAlert } from '~/flash'; -import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; -import { __, sprintf } from '~/locale'; -import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; -import searchProjectsQuery from '../queries/search_projects.query.graphql'; - -export default { - i18n: { - defaultDropdownText: __('Select project to create issue'), - noMatchesFound: __('No matches found'), - toggleButtonLabel: __('Toggle project select'), - }, - components: { - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlLoadingIcon, - GlSearchBoxByType, - }, - inject: ['fullPath'], - data() { - return { - projects: [], - search: '', - selectedProject: {}, - shouldSkipQuery: true, - }; - }, - apollo: { - projects: { - query: searchProjectsQuery, - variables() { - return { - fullPath: this.fullPath, - search: this.search, - }; - }, - update: ({ group }) => group.projects.nodes ?? [], - error(error) { - createAlert({ - message: __('An error occurred while loading projects.'), - captureError: true, - error, - }); - }, - skip() { - return this.shouldSkipQuery; - }, - debounce: DEBOUNCE_DELAY, - }, - }, - computed: { - dropdownHref() { - return this.hasSelectedProject - ? joinPaths(this.selectedProject.webUrl, DASH_SCOPE, 'issues/new') - : undefined; - }, - dropdownText() { - return this.hasSelectedProject - ? sprintf(__('New issue in %{project}'), { project: this.selectedProject.name }) - : this.$options.i18n.defaultDropdownText; - }, - hasSelectedProject() { - return this.selectedProject.id; - }, - projectsWithIssuesEnabled() { - return this.projects.filter((project) => project.issuesEnabled); - }, - showNoSearchResultsText() { - return !this.projectsWithIssuesEnabled.length && this.search; - }, - }, - methods: { - handleDropdownClick() { - if (!this.dropdownHref) { - this.$refs.dropdown.show(); - } - }, - handleDropdownShown() { - if (this.shouldSkipQuery) { - this.shouldSkipQuery = false; - } - this.$refs.search.focusInput(); - }, - selectProject(project) { - this.selectedProject = project; - }, - }, -}; -</script> - -<template> - <gl-dropdown - ref="dropdown" - right - split - :split-href="dropdownHref" - :text="dropdownText" - :toggle-text="$options.i18n.toggleButtonLabel" - variant="confirm" - @click="handleDropdownClick" - @shown="handleDropdownShown" - > - <gl-search-box-by-type ref="search" v-model.trim="search" /> - <gl-loading-icon v-if="$apollo.queries.projects.loading" /> - <template v-else> - <gl-dropdown-item - v-for="project of projectsWithIssuesEnabled" - :key="project.id" - @click="selectProject(project)" - > - {{ project.nameWithNamespace }} - </gl-dropdown-item> - <gl-dropdown-text v-if="showNoSearchResultsText"> - {{ $options.i18n.noMatchesFound }} - </gl-dropdown-text> - </template> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 87184799d5f..31a43c95f5e 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -76,7 +76,6 @@ export const SPECIAL_FILTER = 'specialFilter'; export const ALTERNATIVE_FILTER = 'alternativeFilter'; export const i18n = { - anonymousSearchingMessage: __('You must sign in to search for specific terms.'), calendarLabel: __('Subscribe to calendar'), closed: __('CLOSED'), closedMoved: __('CLOSED (MOVED)'), @@ -105,6 +104,7 @@ export const i18n = { noIssuesDescription: __('Learn more about issues.'), noIssuesTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), noIssuesSignedOutButtonText: __('Register / Sign In'), + noSearchNoFilterTitle: __('Please select at least one filter to see results'), noSearchResultsDescription: __('To widen your search, change or remove filters above'), noSearchResultsTitle: __('Sorry, your filter produced no results'), relatedMergeRequests: __('Related merge requests'), diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js index 5ef61727a3d..b590006929a 100644 --- a/app/assets/javascripts/issues/list/graphql.js +++ b/app/assets/javascripts/issues/list/graphql.js @@ -22,4 +22,6 @@ const resolvers = { }, }; -export const gqlClient = createDefaultClient(resolvers); +export const gqlClient = gon.features?.frontendCaching + ? createDefaultClient(resolvers, { localCacheKey: 'issues_list' }) + : createDefaultClient(resolvers); diff --git a/app/assets/javascripts/issues/list/has_new_issue_dropdown_mixin.js b/app/assets/javascripts/issues/list/has_new_issue_dropdown_mixin.js new file mode 100644 index 00000000000..510edf9b78c --- /dev/null +++ b/app/assets/javascripts/issues/list/has_new_issue_dropdown_mixin.js @@ -0,0 +1,18 @@ +import searchProjectsQuery from './queries/search_projects.query.graphql'; + +export const hasNewIssueDropdown = () => ({ + inject: ['fullPath'], + computed: { + newIssueDropdownQueryVariables() { + return { + fullPath: this.fullPath, + }; + }, + }, + methods: { + extractProjects(data) { + return data?.group?.projects?.nodes; + }, + }, + searchProjectsQuery, +}); diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index 7b68b7432c9..aca894549e4 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -78,7 +78,6 @@ export function mountIssuesListApp() { importCsvIssuesPath, initialEmail, initialSort, - isAnonymousSearchDisabled, isIssueRepositioningDisabled, isProject, isPublicVisibilityRestricted, @@ -127,7 +126,6 @@ export function mountIssuesListApp() { hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature), hasOkrsFeature: parseBoolean(hasOkrsFeature), initialSort, - isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled), isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled), isProject: parseBoolean(isProject), isPublicVisibilityRestricted: parseBoolean(isPublicVisibilityRestricted), 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 ee97fb6edca..1018848fb53 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql @@ -31,7 +31,7 @@ query getIssues( $firstPageSize: Int $lastPageSize: Int ) { - group(fullPath: $fullPath) @skip(if: $isProject) { + group(fullPath: $fullPath) @skip(if: $isProject) @persist { id issues( includeSubgroups: true @@ -58,16 +58,18 @@ query getIssues( first: $firstPageSize last: $lastPageSize ) { + __persist pageInfo { ...PageInfo } nodes { + __persist ...IssueFragment reference(full: true) } } } - project(fullPath: $fullPath) @include(if: $isProject) { + project(fullPath: $fullPath) @include(if: $isProject) @persist { id issues( iid: $iid @@ -95,10 +97,12 @@ query getIssues( first: $firstPageSize last: $lastPageSize ) { + __persist pageInfo { ...PageInfo } nodes { + __persist ...IssueFragment } } diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql index 040763f2ba4..3b49c0efb14 100644 --- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql @@ -20,6 +20,7 @@ fragment IssueFragment on Issue { type assignees @skip(if: $hideUsers) { nodes { + __persist id avatarUrl name @@ -28,6 +29,7 @@ fragment IssueFragment on Issue { } } author @skip(if: $hideUsers) { + __persist id avatarUrl name @@ -36,6 +38,7 @@ fragment IssueFragment on Issue { } labels { nodes { + __persist id color title @@ -43,6 +46,7 @@ fragment IssueFragment on Issue { } } milestone { + __persist id dueDate startDate diff --git a/app/assets/javascripts/issues/list/queries/search_labels.query.graphql b/app/assets/javascripts/issues/list/queries/search_labels.query.graphql index 44b57317161..7c2aa19046c 100644 --- a/app/assets/javascripts/issues/list/queries/search_labels.query.graphql +++ b/app/assets/javascripts/issues/list/queries/search_labels.query.graphql @@ -1,18 +1,22 @@ #import "./label.fragment.graphql" query searchLabels($fullPath: ID!, $search: String, $isProject: Boolean = false) { - group(fullPath: $fullPath) @skip(if: $isProject) { + group(fullPath: $fullPath) @skip(if: $isProject) @persist { id labels(searchTerm: $search, includeAncestorGroups: true, includeDescendantGroups: true) { + __persist nodes { + __persist ...Label } } } - project(fullPath: $fullPath) @include(if: $isProject) { + project(fullPath: $fullPath) @include(if: $isProject) @persist { id labels(searchTerm: $search, includeAncestorGroups: true) { + __persist nodes { + __persist ...Label } } diff --git a/app/assets/javascripts/issues/list/queries/search_projects.query.graphql b/app/assets/javascripts/issues/list/queries/search_projects.query.graphql index bd2f9bc2340..2fd37489234 100644 --- a/app/assets/javascripts/issues/list/queries/search_projects.query.graphql +++ b/app/assets/javascripts/issues/list/queries/search_projects.query.graphql @@ -1,10 +1,9 @@ query searchProjects($fullPath: ID!, $search: String) { group(fullPath: $fullPath) { id - projects(search: $search, includeSubgroups: true) { + projects(search: $search, withIssuesEnabled: true, includeSubgroups: true) { nodes { id - issuesEnabled name nameWithNamespace webUrl diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index e5428f87095..decb559ee81 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -3,10 +3,11 @@ import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gi import Visibility from 'visibilityjs'; import { createAlert } from '~/flash'; import { - IssuableStatus, IssuableStatusText, + STATUS_CLOSED, + TYPE_EPIC, + TYPE_ISSUE, WorkspaceType, - IssuableType, } from '~/issues/constants'; import Poll from '~/lib/utils/poll'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -156,7 +157,7 @@ export default { issuableType: { type: String, required: false, - default: IssuableType.Issue, + default: TYPE_ISSUE, }, canAttachFile: { type: Boolean, @@ -190,6 +191,11 @@ export default { required: false, default: null, }, + issueIid: { + type: Number, + required: false, + default: null, + }, }, data() { const store = new Store({ @@ -251,7 +257,7 @@ export default { return sprintf(__('Error updating %{issuableType}'), { issuableType: this.issuableType }); }, isClosed() { - return this.issuableStatus === IssuableStatus.Closed; + return this.issuableStatus === STATUS_CLOSED; }, pinnedLinkClasses() { return this.showTitleBorder @@ -259,7 +265,7 @@ export default { : ''; }, statusIcon() { - if (this.issuableType === IssuableType.Issue) { + if (this.issuableType === TYPE_ISSUE) { return this.isClosed ? 'issue-closed' : 'issues'; } return this.isClosed ? 'epic-closed' : 'epic'; @@ -271,7 +277,7 @@ export default { return IssuableStatusText[this.issuableStatus]; }, shouldShowStickyHeader() { - return [IssuableType.Issue, IssuableType.Epic].includes(this.issuableType); + return [TYPE_ISSUE, TYPE_EPIC].includes(this.issuableType); }, }, created() { @@ -453,7 +459,7 @@ export default { } }, - handleListItemReorder(description) { + handleSaveDescription(description) { this.updateFormState(); this.setFormState({ description }); this.updateIssuable(); @@ -564,6 +570,7 @@ export default { <component :is="descriptionComponent" :issue-id="issueId" + :issue-iid="issueIid" :can-update="canUpdate" :description-html="state.descriptionHtml" :description-text="state.descriptionText" @@ -573,7 +580,7 @@ export default { :update-url="updateEndpoint" :lock-version="state.lock_version" :is-updating="formState.updateLoading" - @listItemReorder="handleListItemReorder" + @saveDescription="handleSaveDescription" @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 78e729b97da..188a6f6b15e 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -1,13 +1,15 @@ <script> -import { GlToast, GlTooltip, GlModalDirective } from '@gitlab/ui'; +import { GlModalDirective, GlToast } from '@gitlab/ui'; import $ from 'jquery'; +import { uniqueId } from 'lodash'; import Sortable from 'sortablejs'; import Vue from 'vue'; +import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import { createAlert } from '~/flash'; -import { IssuableType } from '~/issues/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import { isMetaKey } from '~/lib/utils/common_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; @@ -15,22 +17,30 @@ import { __, s__, sprintf } from '~/locale'; import { getSortableDefaultOptions, isDragging } from '~/sortable/utils'; import TaskList from '~/task_list'; import Tracking from '~/tracking'; +import addHierarchyChildMutation from '~/work_items/graphql/add_hierarchy_child.mutation.graphql'; +import removeHierarchyChildMutation from '~/work_items/graphql/remove_hierarchy_child.mutation.graphql'; +import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; +import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; -import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql'; - import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_CREATING, + I18N_WORK_ITEM_ERROR_DELETING, TRACKING_CATEGORY_SHOW, TASK_TYPE_NAME, - WIDGET_TYPE_DESCRIPTION, } from '~/work_items/constants'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import eventHub from '../event_hub'; import animateMixin from '../mixins/animate'; -import { convertDescriptionWithNewSort } from '../utils'; +import { + deleteTaskListItem, + convertDescriptionWithNewSort, + extractTaskTitleAndDescription, +} from '../utils'; +import TaskListItemActions from './task_list_item_actions.vue'; Vue.use(GlToast); @@ -44,11 +54,10 @@ export default { GlModal: GlModalDirective, }, components: { - GlTooltip, WorkItemDetailModal, }, mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()], - inject: ['fullPath'], + inject: ['fullPath', 'hasIterationsFeature'], props: { canUpdate: { type: Boolean, @@ -71,7 +80,7 @@ export default { issuableType: { type: String, required: false, - default: IssuableType.Issue, + default: TYPE_ISSUE, }, updateUrl: { type: String, @@ -88,6 +97,11 @@ export default { required: false, default: null, }, + issueIid: { + type: Number, + required: false, + default: null, + }, isUpdating: { type: Boolean, required: false, @@ -98,18 +112,29 @@ export default { const workItemId = getParameterByName('work_item_id'); return { + hasTaskListItemActions: false, preAnimation: false, pulseAnimation: false, initialUpdate: true, - taskButtons: [], + issueDetails: {}, activeTask: {}, workItemId: isPositiveInteger(workItemId) - ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId) + ? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId) : undefined, workItemTypes: [], }; }, apollo: { + issueDetails: { + query: getIssueDetailsQuery, + variables() { + return { + fullPath: this.fullPath, + iid: String(this.issueIid), + }; + }, + update: (data) => data.workspace?.issuable, + }, workItem: { query: workItemQuery, variables() { @@ -118,7 +143,7 @@ export default { }; }, skip() { - return !this.workItemId || !this.workItemsEnabled; + return !this.workItemId || !this.workItemsMvcEnabled; }, }, workItemTypes: { @@ -132,19 +157,19 @@ export default { return data.workspace?.workItemTypes?.nodes; }, skip() { - return !this.workItemsEnabled; + return !this.workItemsMvcEnabled; }, }, }, computed: { - workItemsEnabled() { - return this.glFeatures.workItemsCreateFromMarkdown; + workItemsMvcEnabled() { + return this.glFeatures.workItemsMvc; }, taskWorkItemType() { return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id; }, issueGid() { - return this.issueId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issueId) : null; + return this.issueId ? convertToGraphQLId(TYPENAME_WORK_ITEM, this.issueId) : null; }, }, watch: { @@ -164,10 +189,13 @@ export default { }, }, mounted() { + eventHub.$on('convert-task-list-item', this.convertTaskListItem); + eventHub.$on('delete-task-list-item', this.deleteTaskListItem); + this.renderGFM(); this.updateTaskStatusText(); - if (this.workItemId && this.workItemsEnabled) { + if (this.workItemId && this.workItemsMvcEnabled) { const taskLink = this.$el.querySelector( `.gfm-issue[data-issue="${getIdFromGraphQLId(this.workItemId)}"]`, ); @@ -175,6 +203,9 @@ export default { } }, beforeDestroy() { + eventHub.$off('convert-task-list-item', this.convertTaskListItem); + eventHub.$off('delete-task-list-item', this.deleteTaskListItem); + this.removeAllPointerEventListeners(); }, methods: { @@ -197,8 +228,8 @@ export default { this.renderSortableLists(); - if (this.workItemsEnabled) { - this.renderTaskActions(); + if (this.workItemsMvcEnabled) { + this.renderTaskListItemActions(); } } }, @@ -223,7 +254,7 @@ export default { handle: '.drag-icon', onUpdate: (event) => { const description = convertDescriptionWithNewSort(this.descriptionText, event.to); - this.$emit('listItemReorder', description); + this.$emit('saveDescription', description); }, }), ); @@ -232,29 +263,29 @@ export default { createDragIconElement() { const container = document.createElement('div'); // eslint-disable-next-line no-unsanitized/property - 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> + container.innerHTML = `<svg class="drag-icon s14 gl-icon gl-cursor-grab gl-opacity-0" role="img" aria-hidden="true"> + <use href="${gon.sprite_icons}#grip"></use> </svg>`; return container.firstChild; }, - addPointerEventListeners(listItem, iconSelector) { + addPointerEventListeners(listItem, elementSelector) { const pointeroverListener = (event) => { - const icon = event.target.closest('li').querySelector(iconSelector); - if (!icon || isDragging() || this.isUpdating) { + const element = event.target.closest('li').querySelector(elementSelector); + if (!element || isDragging() || this.isUpdating) { return; } - icon.style.visibility = 'visible'; + element.classList.add('gl-opacity-10'); }; const pointeroutListener = (event) => { - const icon = event.target.closest('li').querySelector(iconSelector); - if (!icon) { + const element = event.target.closest('li').querySelector(elementSelector); + if (!element) { return; } - icon.style.visibility = 'hidden'; + element.classList.remove('gl-opacity-10'); }; // 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. + // list item with children, the grip icons of its children do not become visible. listItem.addEventListener('pointerover', pointeroverListener); listItem.addEventListener('pointerout', pointeroutListener); @@ -279,11 +310,9 @@ export default { taskListUpdateStarted() { this.$emit('taskListUpdateStarted'); }, - taskListUpdateSuccess() { this.$emit('taskListUpdateSucceeded'); }, - taskListUpdateError() { createAlert({ message: sprintf( @@ -298,7 +327,6 @@ export default { this.$emit('taskListUpdateFailed'); }, - updateTaskStatusText() { const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/); const $issuableHeader = $('.issuable-meta'); @@ -317,22 +345,42 @@ export default { $tasksShort.text(''); } }, - renderTaskActions() { + createTaskListItemActions(provide) { + const app = new Vue({ + el: document.createElement('div'), + provide, + render: (createElement) => createElement(TaskListItemActions), + }); + return app.$el; + }, + convertTaskListItem(sourcepos) { + const oldDescription = this.descriptionText; + const { newDescription, taskDescription, taskTitle } = deleteTaskListItem( + oldDescription, + sourcepos, + ); + this.$emit('saveDescription', newDescription); + this.createTask({ taskTitle, taskDescription, oldDescription }); + }, + deleteTaskListItem(sourcepos) { + const { newDescription } = deleteTaskListItem(this.descriptionText, sourcepos); + this.$emit('saveDescription', newDescription); + }, + renderTaskListItemActions() { if (!this.$el?.querySelectorAll) { return; } - this.taskButtons = []; const taskListFields = this.$el.querySelectorAll('.task-list-item:not(.inapplicable)'); - taskListFields.forEach((item, index) => { + taskListFields.forEach((item) => { const taskLink = item.querySelector('.gfm-issue'); if (taskLink) { const { issue, referenceType, issueType } = taskLink.dataset; if (issueType !== workItemTypes.TASK) { return; } - const workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue); + const workItemId = convertToGraphQLId(TYPENAME_WORK_ITEM, issue); this.addHoverListeners(taskLink, workItemId); taskLink.classList.add('gl-link'); taskLink.addEventListener('click', (e) => { @@ -351,31 +399,12 @@ export default { }); return; } - this.addPointerEventListeners(item, '.js-add-task'); - const button = document.createElement('button'); - button.classList.add( - 'btn', - 'btn-default', - 'btn-md', - 'gl-button', - 'btn-default-tertiary', - 'gl-visibility-hidden', - 'gl-p-0!', - 'gl-mt-n1', - 'gl-ml-3', - 'js-add-task', - ); - button.id = `js-task-button-${index}`; - this.taskButtons.push(button.id); - // eslint-disable-next-line no-unsanitized/property - button.innerHTML = ` - <svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14"> - <use href="${gon.sprite_icons}#doc-new"></use> - </svg> - `; - button.setAttribute('aria-label', s__('WorkItem|Create task')); - button.addEventListener('click', () => this.handleCreateTask(button)); - this.insertButtonNextToTaskText(item, button); + + const toggleClass = uniqueId('task-list-item-actions-'); + const dropdown = this.createTaskListItemActions({ canUpdate: this.canUpdate, toggleClass }); + this.addPointerEventListeners(item, `.${toggleClass}`); + this.insertNextToTaskListItemText(dropdown, item); + this.hasTaskListItemActions = true; }); }, addHoverListeners(taskLink, id) { @@ -391,19 +420,20 @@ export default { } }); }, - insertButtonNextToTaskText(listItem, button) { - const paragraph = Array.from(listItem.children).find((element) => element.tagName === 'P'); - const lastChild = listItem.lastElementChild; + insertNextToTaskListItemText(element, listItem) { + const children = Array.from(listItem.children); + const paragraph = children.find((el) => el.tagName === 'P'); + const list = children.find((el) => el.classList.contains('task-list')); if (paragraph) { // If there's a `p` element, then it's a multi-paragraph task item // and the task text exists within the `p` element as the last child - paragraph.append(button); - } else if (lastChild.tagName === 'OL' || lastChild.tagName === 'UL') { + paragraph.append(element); + } else if (list) { // Otherwise, the task item can have a child list which exists directly after the task text - lastChild.insertAdjacentElement('beforebegin', button); + list.insertAdjacentElement('beforebegin', element); } else { // Otherwise, the task item is a simple one where the task text exists as the last child - listItem.append(button); + listItem.append(element); } }, setActiveTask(el) { @@ -427,55 +457,90 @@ export default { this.workItemId = undefined; this.updateWorkItemIdUrlQuery(undefined); }, - async handleCreateTask(el) { - this.setActiveTask(el); + async createTask({ taskTitle, taskDescription, oldDescription }) { try { - const { data } = await this.$apollo.mutate({ - mutation: createWorkItemFromTaskMutation, - variables: { - input: { - id: this.issueGid, - workItemData: { - lockVersion: this.lockVersion, - title: this.activeTask.title, - lineNumberStart: Number(this.activeTask.lineNumberStart), - lineNumberEnd: Number(this.activeTask.lineNumberEnd), - workItemTypeId: this.taskWorkItemType, - }, - }, + const { title, description } = extractTaskTitleAndDescription(taskTitle, taskDescription); + const iterationInput = { + iterationWidget: { + iterationId: this.issueDetails.iteration?.id ?? null, }, - update(store, { data: { workItemCreateFromTask } }) { - const { newWorkItem } = workItemCreateFromTask; - - store.writeQuery({ - query: workItemQuery, - variables: { - id: newWorkItem.id, - }, - data: { - workItem: newWorkItem, - }, - }); + }; + const input = { + confidential: this.issueDetails.confidential, + description, + hierarchyWidget: { + parentId: this.issueGid, + }, + ...(this.hasIterationsFeature && iterationInput), + milestoneWidget: { + milestoneId: this.issueDetails.milestone?.id ?? null, }, + projectPath: this.fullPath, + title, + workItemTypeId: this.taskWorkItemType, + }; + + const { data } = await this.$apollo.mutate({ + mutation: createWorkItemMutation, + variables: { input }, }); - const { workItem, newWorkItem } = data.workItemCreateFromTask; + const { workItem, errors } = data.workItemCreate; + + if (errors?.length) { + throw new Error(errors); + } - const updatedDescription = workItem?.widgets?.find( - (widget) => widget.type === WIDGET_TYPE_DESCRIPTION, - )?.descriptionHtml; + await this.$apollo.mutate({ + mutation: addHierarchyChildMutation, + variables: { id: this.issueGid, workItem }, + }); - this.$emit('updateDescription', updatedDescription); - this.workItemId = newWorkItem.id; - this.openWorkItemDetailModal(el); + this.$toast.show(s__('WorkItem|Converted to task'), { + action: { + text: s__('WorkItem|Undo'), + onClick: (_, toast) => { + this.undoCreateTask(oldDescription, workItem.id); + toast.hide(); + }, + }, + }); } catch (error) { - createAlert({ - message: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemTypes.TASK), - error, - captureError: true, + this.showAlert(I18N_WORK_ITEM_ERROR_CREATING, error); + } + }, + async undoCreateTask(oldDescription, id) { + this.$emit('saveDescription', oldDescription); + + try { + const { data } = await this.$apollo.mutate({ + mutation: deleteWorkItemMutation, + variables: { input: { id } }, + }); + + const { errors } = data.workItemDelete; + + if (errors?.length) { + throw new Error(errors); + } + + await this.$apollo.mutate({ + mutation: removeHierarchyChildMutation, + variables: { id: this.issueGid, workItem: { id } }, }); + + this.$toast.show(s__('WorkItem|Task reverted')); + } catch (error) { + this.showAlert(I18N_WORK_ITEM_ERROR_DELETING, error); } }, + showAlert(message, error) { + createAlert({ + message: sprintfWorkItem(message, workItemTypes.TASK), + error, + captureError: true, + }); + }, handleDeleteTask(description) { this.$emit('updateDescription', description); this.$toast.show(s__('WorkItem|Task deleted')); @@ -492,14 +557,7 @@ export default { </script> <template> - <div - v-if="descriptionHtml" - :class="{ - 'js-task-list-container': canUpdate, - 'work-items-enabled': workItemsEnabled, - }" - class="description" - > + <div v-if="descriptionHtml" :class="{ 'js-task-list-container': canUpdate }" class="description"> <div ref="gfm-content" v-safe-html:[$options.safeHtmlConfig]="descriptionHtml" @@ -507,10 +565,10 @@ export default { :class="{ 'issue-realtime-pre-pulse': preAnimation, 'issue-realtime-trigger-pulse': pulseAnimation, + 'has-task-list-item-actions': hasTaskListItemActions, }" class="md" ></div> - <textarea v-if="descriptionText" :value="descriptionText" @@ -531,10 +589,5 @@ export default { @workItemDeleted="handleDeleteTask" @close="closeWorkItemDetailModal" /> - <template v-if="workItemsEnabled"> - <gl-tooltip v-for="item in taskButtons" :key="item" :target="item"> - {{ s__('WorkItem|Create task') }} - </gl-tooltip> - </template> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index 04c5007dbec..3bc24e8ce01 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; @@ -35,6 +36,16 @@ export default { default: true, }, }, + data() { + return { + formFieldProps: { + id: 'issue-description', + name: 'issue-description', + placeholder: __('Write a comment or drag your files here…'), + 'aria-label': __('Description'), + }, + }; + }, computed: { quickActionsDocsPath() { return helpPagePath('user/project/quick_actions'); @@ -60,10 +71,7 @@ export default { :value="value" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :form-field-aria-label="__('Description')" - :form-field-placeholder="__('Write a comment or drag your files here…')" - form-field-id="issue-description" - form-field-name="issue-description" + :form-field-props="formFieldProps" :quick-actions-docs-path="quickActionsDocsPath" :enable-autocomplete="enableAutocomplete" supports-quick-actions @@ -84,15 +92,13 @@ export default { > <template #textarea> <textarea - id="issue-description" + v-bind="formFieldProps" ref="textarea" :value="value" class="note-textarea js-gfm-input js-autosize markdown-area" data-qa-selector="description_field" dir="auto" data-supports-quick-actions="true" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" @input="$emit('input', $event.target.value)" @keydown.meta.enter="updateIssuable" @keydown.ctrl.enter="updateIssuable" diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue index 5695efd7114..5ade1a86d30 100644 --- a/app/assets/javascripts/issues/show/components/fields/type.vue +++ b/app/assets/javascripts/issues/show/components/fields/type.vue @@ -1,6 +1,6 @@ <script> -import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; -import { capitalize } from 'lodash'; +import { GlFormGroup, GlIcon, GlListbox } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; import { issuableTypes, INCIDENT_TYPE } from '../../constants'; import getIssueStateQuery from '../../queries/get_issue_state.query.graphql'; @@ -16,34 +16,35 @@ export default { components: { GlFormGroup, GlIcon, - GlDropdown, - GlDropdownItem, + GlListbox, }, inject: { canCreateIncident: { default: false, }, issueType: { - default: 'issue', + default: TYPE_ISSUE, }, }, data() { return { issueState: {}, + selectedIssueType: '', }; }, apollo: { issueState: { query: getIssueStateQuery, + result({ + data: { + issueState: { issueType }, + }, + }) { + this.selectedIssueType = issueType; + }, }, }, computed: { - dropdownText() { - const { - issueState: { issueType }, - } = this; - return issuableTypes.find((type) => type.value === issueType)?.text || capitalize(issueType); - }, shouldShowIncident() { return this.issueType === INCIDENT_TYPE || this.canCreateIncident; }, @@ -72,25 +73,21 @@ export default { label-for="issuable-type" class="mb-2 mb-md-0" > - <gl-dropdown - id="issuable-type" - :aria-labelledby="$options.i18n.label" - :text="dropdownText" + <gl-listbox + v-model="selectedIssueType" + toggle-class="gl-mb-0" + :items="$options.issuableTypes" :header-text="$options.i18n.label" - class="gl-w-full" - toggle-class="dropdown-menu-toggle" + :list-aria-labelled-by="$options.i18n.label" + block + @select="updateIssueType" > - <gl-dropdown-item - v-for="type in $options.issuableTypes" - v-show="isShown(type)" - :key="type.value" - :is-checked="issueState.issueType === type.value" - is-check-item - @click="updateIssueType(type.value)" - > - <gl-icon :name="type.icon" /> - {{ type.text }} - </gl-dropdown-item> - </gl-dropdown> + <template #list-item="{ item }"> + <span v-show="isShown(item)" data-testid="issue-type-list-item"> + <gl-icon :name="item.icon" /> + {{ item.text }} + </span> + </template> + </gl-listbox> </gl-form-group> </template> diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index b56c91d7983..bcea9cf57a7 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -1,7 +1,7 @@ <script> import { GlAlert } from '@gitlab/ui'; import { getDraft, updateDraft, getLockVersion, clearDraft } from '~/lib/utils/autosave'; -import { IssuableType } from '~/issues/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import eventHub from '../event_hub'; import EditActions from './edit_actions.vue'; import DescriptionField from './fields/description.vue'; @@ -98,7 +98,7 @@ export default { return this.formState.lockedWarningVisible && !this.formState.updateLoading; }, isIssueType() { - return this.issuableType === IssuableType.Issue; + return this.issuableType === TYPE_ISSUE; }, }, watch: { diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 56e360c75e3..9d92b5cf954 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -12,7 +12,7 @@ import { import { mapActions, mapGetters, mapState } from 'vuex'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; -import { IssuableStatus, IssueType } from '~/issues/constants'; +import { IssueType, STATUS_CLOSED } from '~/issues/constants'; import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -98,6 +98,12 @@ export default { submitAsSpamPath: { default: '', }, + reportedUserId: { + default: '', + }, + reportedFromUrl: { + default: '', + }, }, data() { return { @@ -108,7 +114,7 @@ export default { ...mapState(['isToggleStateButtonLoading']), ...mapGetters(['openState', 'getBlockedByIssues']), isClosed() { - return this.openState === IssuableStatus.Closed; + return this.openState === STATUS_CLOSED; }, issueTypeText() { const issueTypeTexts = { @@ -368,7 +374,12 @@ export default { :title="deleteButtonText" /> + <!-- IMPORTANT: show this component lazily because it causes layout thrashing --> + <!-- https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396 --> <abuse-category-selector + v-if="isReportAbuseDrawerOpen" + :reported-user-id="reportedUserId" + :reported-from-url="reportedFromUrl" :show-drawer="isReportAbuseDrawerOpen" @close-drawer="toggleReportAbuseDrawer(false)" /> diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js index 2fdae538902..c0aadf9c14e 100644 --- a/app/assets/javascripts/issues/show/components/incidents/constants.js +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -47,9 +47,21 @@ export const timelineItemI18n = Object.freeze({ export const timelineEventTagsI18n = Object.freeze({ startTime: __('Start time'), + impactDetected: __('Impact detected'), + responseInitiated: __('Response initiated'), + impactMitigated: __('Impact mitigated'), + causeIdentified: __('Cause identified'), endTime: __('End time'), }); +export const timelineEventTagsPopover = Object.freeze({ + title: s__('Incident|Event tag'), + message: s__( + 'Incident|Adding an event tag associates the timeline comment with specific incident metrics.', + ), + link: __('Learn more'), +}); + export const MAX_TEXT_LENGTH = 280; export const TIMELINE_EVENT_TAGS = Object.values(timelineEventTagsI18n).map((item) => ({ diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue index 81111d42b39..40cb7fbb0ff 100644 --- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue @@ -5,7 +5,7 @@ import { GlIcon } from '@gitlab/ui'; import { sprintf } from '~/locale'; import { createAlert } from '~/flash'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE } from '~/graphql_shared/constants'; import { timelineFormI18n } from './constants'; import TimelineEventsForm from './timeline_events_form.vue'; @@ -41,7 +41,7 @@ export default { } const variables = { - incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + incidentId: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId), fullPath: this.fullPath, }; @@ -71,7 +71,7 @@ export default { mutation: CreateTimelineEvent, variables: { input: { - incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + incidentId: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId), note: eventDetails.note, occurredAt: eventDetails.occurredAt, timelineEventTagNames: eventDetails.timelineEventTags, @@ -113,13 +113,13 @@ export default { > <div v-if="hasTimelineEvents" - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-z-index-1" > <gl-icon name="comment" class="note-icon" /> </div> <timeline-events-form ref="eventForm" - :class="{ 'gl-border-gray-50 gl-border-t': hasTimelineEvents }" + :class="{ 'gl-border-gray-50 gl-border-t gl-pt-3': hasTimelineEvents }" :is-event-processed="createTimelineEventActive" show-save-and-add @save-event="createIncidentTimelineEvent" diff --git a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue index 4ef9b9c5a99..c2fb8b6f683 100644 --- a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue @@ -28,9 +28,9 @@ export default { </script> <template> - <div class="gl-relative gl-display-flex gl-align-items-center"> + <div class="edit-timeline-event gl-relative gl-display-flex gl-align-items-center"> <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-z-index-1" > <gl-icon name="comment" class="note-icon" /> </div> @@ -40,6 +40,7 @@ export default { :is-event-processed="editTimelineEventActive" :previous-occurred-at="event.occurredAt" :previous-note="event.note" + :previous-tags="event.timelineEventTags.nodes" is-editing @save-event="saveEvent" @cancel="$emit('hide-edit')" diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql index 54f036268cc..77f955c08dc 100644 --- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql @@ -7,6 +7,12 @@ mutation UpdateTimelineEvent($input: TimelineEventUpdateInput!) { action occurredAt createdAt + timelineEventTags { + nodes { + id + name + } + } } errors } diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index 53956fcb4b2..997fadec602 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -125,8 +125,8 @@ export default { item.classList.toggle('gl-display-none', !isSummaryTab); }); - editButton.classList.toggle('gl-display-none', !isSummaryTab); - editButton.classList.toggle('gl-sm-display-inline-flex!', isSummaryTab); + editButton?.classList.toggle('gl-display-none', !isSummaryTab); + editButton?.classList.toggle('gl-sm-display-inline-flex!', isSummaryTab); } }, }, diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue index 6648e20865d..7944362a40f 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue @@ -1,10 +1,11 @@ <script> -import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlListbox } from '@gitlab/ui'; +import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlCollapsibleListbox } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __, sprintf } from '~/locale'; +import TimelineEventsTagsPopover from './timeline_events_tags_popover.vue'; import { MAX_TEXT_LENGTH, TIMELINE_EVENT_TAGS, timelineFormI18n } from './constants'; -import { getUtcShiftedDate } from './utils'; +import { getUtcShiftedDate, getPreviousEventTags } from './utils'; export default { name: 'TimelineEventsForm', @@ -21,11 +22,12 @@ export default { ], components: { MarkdownField, + TimelineEventsTagsPopover, GlDatepicker, GlFormInput, GlFormGroup, GlButton, - GlListbox, + GlCollapsibleListbox, }, mixins: [glFeatureFlagsMixin()], i18n: timelineFormI18n, @@ -77,7 +79,7 @@ export default { hourPickerInput: placeholderDate.getHours(), minutePickerInput: placeholderDate.getMinutes(), datePickerInput: placeholderDate, - selectedTags: [...this.previousTags], + selectedTags: getPreviousEventTags(this.previousTags), }; }, computed: { @@ -101,19 +103,19 @@ export default { timelineTextCount() { return this.timelineText.length; }, - dropdownText() { + listboxText() { if (!this.selectedTags.length) { return timelineFormI18n.selectTags; } - const dropdownText = + const listboxText = this.selectedTags.length === 1 ? this.selectedTags[0] : sprintf(__('%{numberOfSelectedTags} tags'), { numberOfSelectedTags: this.selectedTags.length, }); - return dropdownText; + return listboxText; }, }, mounted() { @@ -164,11 +166,11 @@ export default { <template> <form class="gl-flex-grow-1 gl-border-gray-50"> - <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row"> - <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5"> + <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-mt-3"> + <gl-form-group :label="__('Date')" class="gl-mr-5"> <gl-datepicker id="incident-date" ref="datepicker" v-model="datePickerInput" /> </gl-form-group> - <div class="gl-display-flex gl-mt-5"> + <div class="gl-display-flex"> <gl-form-group :label="__('Time')"> <div class="gl-display-flex"> <label label-for="timeline-input-hours" class="sr-only"></label> @@ -197,10 +199,15 @@ export default { <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p> </div> </div> - <gl-form-group v-if="glFeatures.incidentEventTags" :label="$options.i18n.tagsLabel"> - <gl-listbox + <gl-form-group v-if="glFeatures.incidentEventTags"> + <label class="gl-display-flex gl-align-items-center gl-gap-3" for="timeline-input-tags"> + {{ $options.i18n.tagsLabel }} + <timeline-events-tags-popover /> + </label> + <gl-collapsible-listbox + id="timeline-input-tags" :selected="selectedTags" - :toggle-text="dropdownText" + :toggle-text="listboxText" :items="tags" :is-check-centered="true" :multiple="true" diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue index 90ee4351e39..d33f3146d64 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue @@ -32,16 +32,19 @@ export default { type: String, required: true, }, - eventTag: { - type: String, + eventTags: { + type: Array, required: false, - default: null, + default: () => [], }, }, computed: { time() { return formatDate(this.occurredAt, 'HH:MM', true); }, + canEditEvent() { + return this.action === 'comment'; + }, }, methods: { getEventIcon, @@ -51,19 +54,24 @@ export default { <template> <div class="timeline-event gl-display-grid"> <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1" > <gl-icon :name="getEventIcon(action)" class="note-icon" /> </div> <div class="timeline-event-note timeline-event-border" data-testid="event-text-container"> - <div class="gl-display-flex gl-align-items-center gl-mb-3"> - <strong class="gl-font-lg" data-testid="event-time"> + <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-gap-3 gl-mb-2"> + <h3 + class="timeline-event-note-date gl-font-weight-bold gl-font-sm gl-my-0" + data-testid="event-time" + > <gl-sprintf :message="$options.i18n.timeUTC"> - <template #time>{{ time }}</template> + <template #time> + <span class="gl-font-lg">{{ time }}</span> + </template> </gl-sprintf> - </strong> - <gl-badge v-if="eventTag" variant="muted" icon="tag" class="gl-ml-3"> - {{ eventTag }} + </h3> + <gl-badge v-for="tag in eventTags" :key="tag.key" variant="muted" icon="tag"> + {{ tag.name }} </gl-badge> </div> <div v-safe-html="noteHtml" class="md"></div> @@ -78,7 +86,7 @@ export default { category="tertiary" no-caret > - <gl-dropdown-item @click="$emit('edit')"> + <gl-dropdown-item v-if="canEditEvent" @click="$emit('edit')"> {{ $options.i18n.edit }} </gl-dropdown-item> <gl-dropdown-item @click="$emit('delete')"> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue index c6b93201c97..10b80529a66 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue @@ -50,9 +50,6 @@ export default { }, }, methods: { - getFirstTag(eventTag) { - return eventTag.nodes?.[0]?.name; - }, handleEditSelection(event) { this.eventToEdit = event.id; this.$emit('hide-new-incident-timeline-event-form'); @@ -105,6 +102,7 @@ export default { id: eventDetails.id, note: eventDetails.note, occurredAt: eventDetails.occurredAt, + timelineEventTagNames: eventDetails.timelineEventTags, }, }, }) @@ -132,21 +130,25 @@ export default { </script> <template> - <div class="issuable-discussion incident-timeline-events"> + <div class="issuable-discussion incident-timeline-events gl-mt-n3"> <div v-for="[eventDate, events] in dateGroupedEvents" :key="eventDate" data-testid="timeline-group" class="timeline-group" > - <div class="gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid"> - <strong class="gl-font-size-h2" data-testid="event-date">{{ eventDate }}</strong> - </div> + <h2 + class="gl-font-size-h2 gl-my-0 gl-py-5 gl-border-gray-50 gl-border-1 gl-border-b-solid" + data-testid="event-date" + > + {{ eventDate }} + </h2> + <ul class="notes main-notes-list"> <li v-for="(event, eventIndex) in events" :key="eventIndex" - class="timeline-entry-vertical-line timeline-entry note system-note note-wrapper gl-my-2! gl-pr-0!" + class="timeline-entry-vertical-line timeline-entry note system-note note-wrapper gl-my-0! gl-pr-0!" > <edit-timeline-event v-if="eventToEdit === event.id" @@ -164,7 +166,7 @@ export default { :action="event.action" :occurred-at="event.occurredAt" :note-html="event.noteHtml" - :event-tag="getFirstTag(event.timelineEventTags)" + :event-tags="event.timelineEventTags.nodes" @delete="handleDelete(event)" @edit="handleEditSelection(event)" /> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue index c8237766505..cb18d34b70b 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE } from '~/graphql_shared/constants'; import { fetchPolicies } from '~/lib/graphql'; import notesEventHub from '~/notes/event_hub'; import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql'; @@ -33,7 +33,7 @@ export default { variables() { return { fullPath: this.fullPath, - incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + incidentId: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId), }; }, update(data) { diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tags_popover.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tags_popover.vue new file mode 100644 index 00000000000..772a16e9ba2 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tags_popover.vue @@ -0,0 +1,42 @@ +<script> +import { GlIcon, GlPopover, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { timelineEventTagsPopover } from './constants'; + +export default { + name: 'TimelineEventsTagsPopover', + components: { + GlIcon, + GlPopover, + GlLink, + }, + i18n: timelineEventTagsPopover, + learnMoreLink: helpPagePath('ee/operations/incident_management/incident_timeline_events', { + anchor: 'incident-tags', + }), +}; +</script> + +<template> + <span> + <gl-icon id="timeline-events-tag-question" name="question-o" class="gl-text-blue-600" /> + + <gl-popover + target="timeline-events-tag-question" + triggers="hover focus" + placement="top" + container="viewport" + :title="$options.i18n.title" + > + <div> + <p class="gl-mb-0"> + {{ $options.i18n.message }} + </p> + <gl-link target="_blank" class="gl-font-sm" :href="$options.learnMoreLink">{{ + $options.i18n.link + }}</gl-link + >. + </div> + </gl-popover> + </span> +</template> diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js index 5a009debd75..ce33e91c3b8 100644 --- a/app/assets/javascripts/issues/show/components/incidents/utils.js +++ b/app/assets/javascripts/issues/show/components/incidents/utils.js @@ -32,3 +32,11 @@ export const getUtcShiftedDate = (ISOString = null) => { return date; }; + +/** + * Returns an array of previously set event tags + * @param {array} timelineEventTagsNodes + * @returns {array} + */ +export const getPreviousEventTags = (timelineEventTagsNodes = []) => + timelineEventTagsNodes.map(({ name }) => name); diff --git a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue new file mode 100644 index 00000000000..d0beb0f39b3 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue @@ -0,0 +1,47 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + i18n: { + convertToTask: s__('WorkItem|Convert to task'), + delete: __('Delete'), + taskActions: s__('WorkItem|Task actions'), + }, + components: { + GlDropdown, + GlDropdownItem, + }, + inject: ['canUpdate', 'toggleClass'], + methods: { + convertToTask() { + eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos); + }, + deleteTaskListItem() { + eventHub.$emit('delete-task-list-item', this.$el.closest('li').dataset.sourcepos); + }, + }, +}; +</script> + +<template> + <gl-dropdown + class="task-list-item-actions-wrapper" + category="tertiary" + icon="ellipsis_v" + lazy + no-caret + right + :text="$options.i18n.taskActions" + text-sr-only + :toggle-class="`task-list-item-actions gl-opacity-0 gl-p-2! ${toggleClass}`" + > + <gl-dropdown-item v-if="canUpdate" @click="convertToTask"> + {{ $options.i18n.convertToTask }} + </gl-dropdown-item> + <gl-dropdown-item v-if="canUpdate" variant="danger" @click="deleteTaskListItem"> + {{ $options.i18n.delete }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/issues/show/graphql.js b/app/assets/javascripts/issues/show/graphql.js deleted file mode 100644 index deee034f9d1..00000000000 --- a/app/assets/javascripts/issues/show/graphql.js +++ /dev/null @@ -1,9 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { defaultClient } from '~/graphql_shared/issuable_client'; - -Vue.use(VueApollo); - -export default new VueApollo({ - defaultClient, -}); diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index 21d877c5fe6..1793ce66ad4 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import { mapGetters } from 'vuex'; import errorTrackingStore from '~/error_tracking/store'; +import { apolloProvider } from '~/graphql_shared/issuable_client'; import { parseBoolean } from '~/lib/utils/common_utils'; import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; import IssueApp from './components/app.vue'; @@ -8,7 +9,6 @@ import HeaderActions from './components/header_actions.vue'; import IncidentTabs from './components/incidents/incident_tabs.vue'; import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue'; import { INCIDENT_TYPE, issueState } from './constants'; -import apolloProvider from './graphql'; import getIssueStateQuery from './queries/get_issue_state.query.graphql'; const bootstrapApollo = (state = {}) => { @@ -20,7 +20,7 @@ const bootstrapApollo = (state = {}) => { }); }; -export function initIncidentApp(issueData = {}) { +export function initIncidentApp(issueData = {}, store) { const el = document.getElementById('js-issuable-app'); if (!el) { @@ -49,6 +49,7 @@ export function initIncidentApp(issueData = {}) { el, name: 'DescriptionRoot', apolloProvider, + store, provide: { issueType: INCIDENT_TYPE, canCreateIncident, @@ -62,6 +63,9 @@ export function initIncidentApp(issueData = {}) { uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable), contentEditorOnIssues: gon.features.contentEditorOnIssues, }, + computed: { + ...mapGetters(['getNoteableData']), + }, render(createElement) { return createElement(IssueApp, { props: { @@ -70,6 +74,7 @@ export function initIncidentApp(issueData = {}) { issuableStatus: state, descriptionComponent: IncidentTabs, showTitleBorder: false, + isConfidential: this.getNoteableData?.confidential, }, }); }, @@ -89,7 +94,12 @@ export function initIssueApp(issueData, store) { bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); - const { canCreateIncident, hasIssueWeightsFeature, ...issueProps } = issueData; + const { + canCreateIncident, + hasIssueWeightsFeature, + hasIterationsFeature, + ...issueProps + } = issueData; return new Vue({ el, @@ -102,6 +112,7 @@ export function initIssueApp(issueData, store) { registerPath, signInPath, hasIssueWeightsFeature, + hasIterationsFeature, }, computed: { ...mapGetters(['getNoteableData']), @@ -114,6 +125,7 @@ export function initIssueApp(issueData, store) { isLocked: this.getNoteableData?.discussion_locked, issuableStatus: this.getNoteableData?.state, issueId: this.getNoteableData?.id, + issueIid: this.getNoteableData?.iid, }, }); }, @@ -152,7 +164,7 @@ export function initHeaderActions(store, type = '') { projectPath: el.dataset.projectPath, projectId: el.dataset.projectId, reportAbusePath: el.dataset.reportAbusePath, - reportedUserId: el.dataset.reportedUserId, + reportedUserId: parseInt(el.dataset.reportedUserId, 10), reportedFromUrl: el.dataset.reportedFromUrl, submitAsSpamPath: el.dataset.submitAsSpamPath, }, diff --git a/app/assets/javascripts/issues/show/utils.js b/app/assets/javascripts/issues/show/utils.js index 05b06586362..7742a015836 100644 --- a/app/assets/javascripts/issues/show/utils.js +++ b/app/assets/javascripts/issues/show/utils.js @@ -1,4 +1,6 @@ +import { TITLE_LENGTH_MAX } from '~/issues/constants'; import { COLON, HYPHEN, NEWLINE } from '~/lib/utils/text_utility'; +import { __ } from '~/locale'; /** * Returns the start and end `sourcepos` rows, converted to zero-based numbering. @@ -93,3 +95,136 @@ export const convertDescriptionWithNewSort = (description, list) => { return descriptionLines.join(NEWLINE); }; + +const bulletTaskListItemRegex = /^\s*[-*]\s+\[.]\s+/; +const numericalTaskListItemRegex = /^\s*[0-9]\.\s+\[.]\s+/; +const codeMarkdownRegex = /^\s*`.*`\s*$/; +const imageOrLinkMarkdownRegex = /^\s*!?\[.*\)\s*$/; + +/** + * Checks whether the line of markdown contains a task list item, + * i.e. `- [ ]`, `* [ ]`, or `1. [ ]`. + * + * @param {String} line A line of markdown + * @returns {boolean} `true` if the line contains a task list item, otherwise `false` + */ +const containsTaskListItem = (line) => + bulletTaskListItemRegex.test(line) || numericalTaskListItemRegex.test(line); + +/** + * Deletes a task list item from the description. + * + * Starting from the task list item, it deletes each line until it hits a nested + * task list item and reduces the indentation of each line from this line onwards. + * + * For example, for a given description like: + * + * <pre> + * 1. [ ] item 1 + * + * paragraph text + * + * 1. [ ] item 2 + * + * paragraph text + * + * 1. [ ] item 3 + * </pre> + * + * Then when prompted to delete item 1, this function will return: + * + * <pre> + * 1. [ ] item 2 + * + * paragraph text + * + * 1. [ ] item 3 + * </pre> + * + * @param {String} description Description in markdown format + * @param {String} sourcepos Source position in format `23:3-23:14` + * @returns {{newDescription: String, taskDescription: String, taskTitle: String}} Object with: + * + * - `newDescription` property that contains markdown with the deleted task list item omitted + * - `taskDescription` property that contains the description of the deleted task list item + * - `taskTitle` property that contains the title of the deleted task list item + */ +export const deleteTaskListItem = (description, sourcepos) => { + const descriptionLines = description.split(NEWLINE); + const [startIndex, endIndex] = getSourceposRows(sourcepos); + + const firstLine = descriptionLines[startIndex]; + const firstLineIndentation = firstLine.length - firstLine.trimStart().length; + + const taskTitle = firstLine + .replace(bulletTaskListItemRegex, '') + .replace(numericalTaskListItemRegex, ''); + const taskDescription = []; + + let indentation = 0; + let linesToDelete = 1; + let reduceIndentation = false; + + for (let i = startIndex + 1; i <= endIndex; i += 1) { + if (reduceIndentation) { + descriptionLines[i] = descriptionLines[i].slice(indentation); + } else if (containsTaskListItem(descriptionLines[i])) { + reduceIndentation = true; + const currentLine = descriptionLines[i]; + const currentLineIndentation = currentLine.length - currentLine.trimStart().length; + indentation = currentLineIndentation - firstLineIndentation; + descriptionLines[i] = descriptionLines[i].slice(indentation); + } else { + taskDescription.push(descriptionLines[i].trimStart()); + linesToDelete += 1; + } + } + + descriptionLines.splice(startIndex, linesToDelete); + + return { + newDescription: descriptionLines.join(NEWLINE), + taskDescription: taskDescription.join(NEWLINE) || undefined, + taskTitle, + }; +}; + +/** + * Given a title and description for a task: + * + * - Moves characters beyond the 255 character limit from the title to the description + * - Moves a pure markdown title to the description and gives the title the value `Untitled` + * + * @param {String} taskTitle The task title + * @param {String} taskDescription The task description + * @returns {{description: String, title: String}} An object with the formatted task title and description + */ +export const extractTaskTitleAndDescription = (taskTitle, taskDescription) => { + const isTitleOnlyMarkdown = + codeMarkdownRegex.test(taskTitle) || imageOrLinkMarkdownRegex.test(taskTitle); + + if (isTitleOnlyMarkdown) { + return { + title: __('Untitled'), + description: taskDescription + ? taskTitle.concat(NEWLINE, NEWLINE, taskDescription) + : taskTitle, + }; + } + + const isTitleTooLong = taskTitle.length > TITLE_LENGTH_MAX; + + if (isTitleTooLong) { + return { + title: taskTitle.slice(0, TITLE_LENGTH_MAX), + description: taskDescription + ? taskTitle.slice(TITLE_LENGTH_MAX).concat(NEWLINE, NEWLINE, taskDescription) + : taskTitle.slice(TITLE_LENGTH_MAX), + }; + } + + return { + title: taskTitle, + description: taskDescription, + }; +}; diff --git a/app/assets/javascripts/jira_connect/subscriptions/api.js b/app/assets/javascripts/jira_connect/subscriptions/api.js index c79d7002111..8c5dc88f183 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/api.js +++ b/app/assets/javascripts/jira_connect/subscriptions/api.js @@ -35,13 +35,16 @@ export const removeSubscription = async (removePath) => { }); }; -export const fetchGroups = async (groupsPath, { page, perPage, search }) => { +export const fetchGroups = async (groupsPath, { page, perPage, search }, accessToken = null) => { return axiosInstance.get(groupsPath, { params: { page, per_page: perPage, search, }, + headers: { + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, }); }; diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue index a9ec7bd971e..a4b728335c5 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue @@ -1,4 +1,5 @@ <script> +import { mapState } from 'vuex'; import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui'; import { fetchGroups } from '~/jira_connect/subscriptions/api'; import { @@ -38,6 +39,7 @@ export default { showPagination() { return this.totalItems > this.$options.DEFAULT_GROUPS_PER_PAGE && this.groups.length > 0; }, + ...mapState(['accessToken']), }, mounted() { return this.loadGroups().finally(() => { @@ -47,11 +49,15 @@ export default { methods: { loadGroups() { this.isLoadingMore = true; - return fetchGroups(this.groupsPath, { - page: this.page, - perPage: this.$options.DEFAULT_GROUPS_PER_PAGE, - search: this.searchValue, - }) + return fetchGroups( + this.groupsPath, + { + page: this.page, + perPage: this.$options.DEFAULT_GROUPS_PER_PAGE, + search: this.searchValue, + }, + this.accessToken, + ) .then((response) => { const { page, total } = parseIntPagination(normalizeHeaders(response.headers)); this.page = page; diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index 44575455a34..ec42b533dd4 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -31,6 +31,9 @@ export default { subscriptionsPath: { default: '', }, + publicKeyStorageEnabled: { + default: false, + }, }, computed: { ...mapState(['currentUser']), @@ -144,6 +147,7 @@ export default { <sign-in-page v-show="!userSignedIn" :has-subscriptions="hasSubscriptions" + :public-key-storage-enabled="publicKeyStorageEnabled" @sign-in-oauth="onSignInOauth" @error="onSignInError" /> diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index 01bc5dfc66b..bb22a4ef252 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js @@ -38,7 +38,7 @@ export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_ anchor: 'use-the-integration', }); export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('integration/jira/connect-app', { - anchor: 'connect-the-gitlabcom-for-jira-cloud-app-for-self-managed-instances', + anchor: 'connect-the-gitlab-for-jira-cloud-app-for-self-managed-instances', }); export const GITLAB_COM_BASE_PATH = 'https://gitlab.com'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js index 8e9f73538b9..21ff85e58e2 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/index.js +++ b/app/assets/javascripts/jira_connect/subscriptions/index.js @@ -27,6 +27,7 @@ export function initJiraConnect() { usersPath, gitlabUserPath, oauthMetadata, + publicKeyStorageEnabled, } = el.dataset; sizeToParent(); @@ -42,6 +43,7 @@ export function initJiraConnect() { usersPath, gitlabUserPath, oauthMetadata: oauthMetadata ? JSON.parse(oauthMetadata) : null, + publicKeyStorageEnabled, }, render(createElement) { return createElement(JiraConnectApp); 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 index 782e8a625a9..6de3f507a39 100644 --- 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 @@ -27,7 +27,7 @@ export default { }, i18n: { signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'), - signInText: s__('JiraService|Sign in to GitLab.com to get started.'), + signInText: s__('JiraService|Sign in to GitLab to get started.'), }, GITLAB_COM_BASE_PATH, methods: { 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 index f4c59b2184e..e6a94ffbaa4 100644 --- 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 @@ -12,10 +12,14 @@ export default { type: Boolean, required: true, }, + publicKeyStorageEnabled: { + type: Boolean, + required: true, + }, }, computed: { isOauthSelfManagedEnabled() { - return this.glFeatures.jiraConnectOauth && this.glFeatures.jiraConnectOauthSelfManaged; + return this.glFeatures.jiraConnectOauth && this.publicKeyStorageEnabled; }, }, }; diff --git a/app/assets/javascripts/jobs/components/job/job_app.vue b/app/assets/javascripts/jobs/components/job/job_app.vue index c6d900ef13e..d93b8a8de29 100644 --- a/app/assets/javascripts/jobs/components/job/job_app.vue +++ b/app/assets/javascripts/jobs/components/job/job_app.vue @@ -9,6 +9,7 @@ import { __, sprintf } from '~/locale'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; import Log from '~/jobs/components/log/log.vue'; +import { MANUAL_STATUS } from '~/jobs/constants'; import EmptyState from './empty_state.vue'; import EnvironmentsBlock from './environments_block.vue'; import ErasedBlock from './erased_block.vue'; @@ -144,6 +145,12 @@ export default { this.fetchJobsForStage(defaultStage); } } + + // Only poll for job log if we are not in the manual variables form empty state. + // This will be handled more elegantly in the future with GraphQL in https://gitlab.com/gitlab-org/gitlab/-/issues/389597 + if (newVal?.status?.group !== MANUAL_STATUS && !this.showUpdateVariablesState) { + this.fetchJobLog(); + } }, }, created() { @@ -163,6 +170,7 @@ export default { }, methods: { ...mapActions([ + 'fetchJobLog', 'fetchJobsForStage', 'hideSidebar', 'showSidebar', diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue index 734d3ca0d49..763eb6705aa 100644 --- a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue @@ -13,8 +13,9 @@ import { cloneDeep, uniqueId } from 'lodash'; import { mapActions } from 'vuex'; import { fetchPolicies } from '~/lib/graphql'; import { createAlert } from '~/flash'; +import { TYPENAME_CI_BUILD, TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { JOB_GRAPHQL_ERRORS, GRAPHQL_ID_TYPES } from '~/jobs/constants'; +import { JOB_GRAPHQL_ERRORS } from '~/jobs/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -45,7 +46,7 @@ export default { variables() { return { fullPath: this.projectPath, - id: convertToGraphQLId(GRAPHQL_ID_TYPES.commitStatus, this.jobId), + id: convertToGraphQLId(TYPENAME_COMMIT_STATUS, this.jobId), }; }, fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, @@ -76,13 +77,16 @@ export default { i18n: { clearInputs: s__('CiVariables|Clear inputs'), formHelpText: s__( - 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', + 'CiVariables|Specify variable values to be used in this run. The variables specified in the configuration file and %{linkStart}CI/CD settings%{linkEnd} are used by default.', + ), + overrideNoteText: s__( + 'CiVariables|Variables specified here are %{boldStart}expanded%{boldEnd} and not %{boldStart}masked.%{boldEnd}', ), header: s__('CiVariables|Variables'), keyLabel: s__('CiVariables|Key'), keyPlaceholder: s__('CiVariables|Input variable key'), runAgainButtonText: s__('CiVariables|Run job again'), - triggerButtonText: s__('CiVariables|Trigger this manual action'), + triggerButtonText: s__('CiVariables|Run job'), valueLabel: s__('CiVariables|Value'), valuePlaceholder: s__('CiVariables|Input variable value'), }, @@ -157,7 +161,7 @@ export default { const { data } = await this.$apollo.mutate({ mutation: retryJobWithVariablesMutation, variables: { - id: convertToGraphQLId(GRAPHQL_ID_TYPES.ciBuild, this.jobId), + id: convertToGraphQLId(TYPENAME_CI_BUILD, this.jobId), // we need to ensure no empty variables are passed to the API variables: this.preparedVariables, }, @@ -258,6 +262,15 @@ export default { </template> </gl-sprintf> </div> + <div class="gl-text-center gl-mt-3"> + <gl-sprintf :message="$options.i18n.overrideNoteText"> + <template #bold="{ content }"> + <strong> + {{ content }} + </strong> + </template> + </gl-sprintf> + </div> <div v-if="isRetryable" class="gl-display-flex gl-justify-content-center gl-mt-5"> <gl-button class="gl-mt-5" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue index 40aec0b0536..8100bc2d87a 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue @@ -2,11 +2,11 @@ import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { createAlert } from '~/flash'; +import { TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { JOB_GRAPHQL_ERRORS, - GRAPHQL_ID_TYPES, JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId, PASSED_STATUS, @@ -35,7 +35,7 @@ export default { variables() { return { fullPath: this.projectPath, - id: convertToGraphQLId(GRAPHQL_ID_TYPES.commitStatus, this.jobId), + id: convertToGraphQLId(TYPENAME_COMMIT_STATUS, this.jobId), }; }, update(data) { 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 6f351d91165..17766b4d162 100644 --- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue @@ -8,9 +8,11 @@ import { ACTIONS_UNSCHEDULE, ACTIONS_PLAY, ACTIONS_RETRY, + ACTIONS_RUN_AGAIN, CANCEL, GENERIC_ERROR, JOB_SCHEDULED, + JOB_SUCCESS, PLAY_JOB_CONFIRMATION_MESSAGE, RUN_JOB_NOW_HEADER_TITLE, FILE_TYPE_ARCHIVE, @@ -107,6 +109,9 @@ export default { shouldDisplayArtifacts() { return this.canReadArtifacts && this.hasArtifacts; }, + retryButtonTitle() { + return this.job.status === JOB_SUCCESS ? ACTIONS_RUN_AGAIN : ACTIONS_RETRY; + }, }, methods: { async postJobAction(name, mutation, redirect = false) { @@ -223,8 +228,8 @@ export default { <gl-button v-else-if="isRetryable" icon="retry" - :title="$options.ACTIONS_RETRY" - :aria-label="$options.ACTIONS_RETRY" + :title="retryButtonTitle" + :aria-label="retryButtonTitle" :method="currentJobMethod" :disabled="retryBtnDisabled" data-testid="retry" diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js index f73241aed6b..41ce6e4d64d 100644 --- a/app/assets/javascripts/jobs/components/table/constants.js +++ b/app/assets/javascripts/jobs/components/table/constants.js @@ -9,6 +9,7 @@ export const RAW_TEXT_WARNING = s__( /* Job Status Constants */ export const JOB_SCHEDULED = 'SCHEDULED'; +export const JOB_SUCCESS = 'SUCCESS'; /* Artifact file types */ export const FILE_TYPE_ARCHIVE = 'ARCHIVE'; @@ -19,6 +20,7 @@ export const ACTIONS_START_NOW = s__('DelayedJobs|Start now'); export const ACTIONS_UNSCHEDULE = s__('DelayedJobs|Unschedule'); export const ACTIONS_PLAY = __('Play'); export const ACTIONS_RETRY = __('Retry'); +export const ACTIONS_RUN_AGAIN = __('Run again'); export const CANCEL = __('Cancel'); export const GENERIC_ERROR = __('An error occurred while making the request.'); diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js index 405aea11181..027d896ba0e 100644 --- a/app/assets/javascripts/jobs/constants.js +++ b/app/assets/javascripts/jobs/constants.js @@ -5,11 +5,6 @@ const moreInfo = __('More information'); export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; -export const GRAPHQL_ID_TYPES = { - commitStatus: 'CommitStatus', - ciBuild: 'Ci::Build', -}; - export const JOB_SIDEBAR_COPY = { cancel, cancelJobButtonLabel: s__('Job|Cancel'), @@ -42,3 +37,4 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = { export const SUCCESS_STATUS = 'SUCCESS'; export const PASSED_STATUS = 'passed'; +export const MANUAL_STATUS = 'manual'; diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index a81edb240ad..af2d720643f 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -8,7 +8,6 @@ import { canScroll, isScrolledToBottom, isScrolledToTop, - isScrolledToMiddle, scrollDown, scrollUp, } from '~/lib/utils/scroll_utils'; @@ -23,7 +22,7 @@ export const init = ({ dispatch }, { endpoint, logState, pagePath }) => { pagePath, }); - return Promise.all([dispatch('fetchJob'), dispatch('fetchJobLog')]); + return Promise.all([dispatch('fetchJob')]); }; export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint); @@ -124,15 +123,15 @@ export const scrollBottom = ({ dispatch }) => { */ export const toggleScrollButtons = ({ dispatch }) => { if (canScroll()) { - if (isScrolledToMiddle()) { - dispatch('enableScrollTop'); - dispatch('enableScrollBottom'); - } else if (isScrolledToTop()) { + if (isScrolledToTop()) { dispatch('disableScrollTop'); dispatch('enableScrollBottom'); } else if (isScrolledToBottom()) { dispatch('disableScrollBottom'); dispatch('enableScrollTop'); + } else { + dispatch('enableScrollTop'); + dispatch('enableScrollBottom'); } } else { dispatch('disableScrollBottom'); diff --git a/app/assets/javascripts/language_switcher/components/app.vue b/app/assets/javascripts/language_switcher/components/app.vue index 4d3fe22e247..a2012f95fd6 100644 --- a/app/assets/javascripts/language_switcher/components/app.vue +++ b/app/assets/javascripts/language_switcher/components/app.vue @@ -45,7 +45,7 @@ export default { :toggle-text="preferredLocale.text" :items="locales" category="tertiary" - right + placement="right" icon="earth" size="small" toggle-class="py-0 gl-h-6" diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index 90c1b31286a..b8138f34d45 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -56,7 +56,11 @@ function initDeferred() { if (!appEl) return; setNotification(appEl); - document.querySelector('.js-whats-new-trigger').addEventListener('click', () => { + + const triggerEl = document.querySelector('.js-whats-new-trigger'); + if (!triggerEl) return; + + triggerEl.addEventListener('click', () => { import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new') .then(({ default: initWhatsNew }) => { initWhatsNew(appEl); diff --git a/app/assets/javascripts/lib/apollo/persist_link.js b/app/assets/javascripts/lib/apollo/persist_link.js new file mode 100644 index 00000000000..9d95409d96c --- /dev/null +++ b/app/assets/javascripts/lib/apollo/persist_link.js @@ -0,0 +1,141 @@ +// this file is based on https://github.com/apollographql/apollo-cache-persist/blob/master/examples/react-native/src/utils/persistence/persistLink.ts +// with some heavy refactororing + +/* eslint-disable consistent-return */ +/* eslint-disable @gitlab/require-i18n-strings */ +/* eslint-disable no-param-reassign */ +import { visit } from 'graphql'; +import { ApolloLink } from '@apollo/client/core'; +import traverse from 'traverse'; + +const extractPersistDirectivePaths = (originalQuery, directive = 'persist') => { + const paths = []; + const fragmentPaths = {}; + const fragmentPersistPaths = {}; + + const query = visit(originalQuery, { + FragmentSpread: ({ name: { value: name } }, _key, _parent, _path, ancestors) => { + const root = ancestors.find( + ({ kind }) => kind === 'OperationDefinition' || kind === 'FragmentDefinition', + ); + + const rootKey = root.kind === 'FragmentDefinition' ? root.name.value : '$ROOT'; + + const fieldPath = ancestors + .filter(({ kind }) => kind === 'Field') + .map(({ name: { value } }) => value); + + fragmentPaths[name] = [rootKey].concat(fieldPath); + }, + Directive: ({ name: { value: name } }, _key, _parent, _path, ancestors) => { + if (name === directive) { + const fieldPath = ancestors + .filter(({ kind }) => kind === 'Field') + .map(({ name: { value } }) => value); + + const fragmentDefinition = ancestors.find(({ kind }) => kind === 'FragmentDefinition'); + + // If we are inside a fragment, we must save the reference. + if (fragmentDefinition) { + fragmentPersistPaths[fragmentDefinition.name.value] = fieldPath; + } else if (fieldPath.length) { + paths.push(fieldPath); + } + return null; + } + }, + }); + + // In case there are any FragmentDefinition items, we need to combine paths. + if (Object.keys(fragmentPersistPaths).length) { + visit(originalQuery, { + FragmentSpread: ({ name: { value: name } }, _key, _parent, _path, ancestors) => { + if (fragmentPersistPaths[name]) { + let fieldPath = ancestors + .filter(({ kind }) => kind === 'Field') + .map(({ name: { value } }) => value); + + fieldPath = fieldPath.concat(fragmentPersistPaths[name]); + + const fragment = name; + let parent = fragmentPaths[fragment][0]; + + while (parent && parent !== '$ROOT' && fragmentPaths[parent]) { + fieldPath = fragmentPaths[parent].slice(1).concat(fieldPath); + // eslint-disable-next-line prefer-destructuring + parent = fragmentPaths[parent][0]; + } + + paths.push(fieldPath); + } + }, + }); + } + + return { query, paths }; +}; + +/** + * Given a data result object path, return the equivalent query selection path. + * + * @param {Array} path The data result object path. i.e.: ["a", 0, "b"] + * @return {String} the query selection path. i.e.: "a.b" + */ +const toQueryPath = (path) => path.filter((key) => Number.isNaN(Number(key))).join('.'); + +const attachPersists = (paths, object) => { + const queryPaths = paths.map(toQueryPath); + function mapperFunction() { + if ( + !this.isRoot && + this.node && + typeof this.node === 'object' && + Object.keys(this.node).length && + !Array.isArray(this.node) + ) { + const path = toQueryPath(this.path); + + this.update({ + __persist: Boolean( + queryPaths.find( + (queryPath) => queryPath.indexOf(path) === 0 || path.indexOf(queryPath) === 0, + ), + ), + ...this.node, + }); + } + } + + return traverse(object).map(mapperFunction); +}; + +export const getPersistLink = () => { + return new ApolloLink((operation, forward) => { + const { query, paths } = extractPersistDirectivePaths(operation.query); + + // Noop if not a persist query + if (!paths.length) { + return forward(operation); + } + + // Replace query with one without @persist directives. + operation.query = query; + + // Remove requesting __persist fields. + operation.query = visit(operation.query, { + Field: ({ name: { value: name } }) => { + if (name === '__persist') { + return null; + } + }, + }); + + return forward(operation).map((result) => { + if (result.data) { + result.data = attachPersists(paths, result.data); + } + + return result; + }); + }); +}; diff --git a/app/assets/javascripts/lib/apollo/persistence_mapper.js b/app/assets/javascripts/lib/apollo/persistence_mapper.js new file mode 100644 index 00000000000..8fc7c69c79d --- /dev/null +++ b/app/assets/javascripts/lib/apollo/persistence_mapper.js @@ -0,0 +1,67 @@ +// this file is based on https://github.com/apollographql/apollo-cache-persist/blob/master/examples/react-native/src/utils/persistence/persistenceMapper.ts +// with some heavy refactororing + +/* eslint-disable @gitlab/require-i18n-strings */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable no-param-reassign */ +/* eslint-disable dot-notation */ +export const persistenceMapper = async (data) => { + const parsed = JSON.parse(data); + + const mapped = {}; + const persistEntities = []; + const rootQuery = parsed['ROOT_QUERY']; + + // cache entities that have `__persist: true` + Object.keys(parsed).forEach((key) => { + if (parsed[key]['__persist']) { + persistEntities.push(key); + } + }); + + // cache root queries that have `@persist` directive + mapped['ROOT_QUERY'] = Object.keys(rootQuery).reduce( + (obj, key) => { + if (key === '__typename') return obj; + + if (/@persist$/.test(key)) { + obj[key] = rootQuery[key]; + + if (Array.isArray(rootQuery[key])) { + const entities = rootQuery[key].map((item) => item.__ref); + persistEntities.push(...entities); + } else { + const entity = rootQuery[key].__ref; + persistEntities.push(entity); + } + } + + return obj; + }, + { __typename: 'Query' }, + ); + + persistEntities.reduce((obj, key) => { + const parsedEntity = parsed[key]; + + // check for root queries and only cache root query properties that have `__persist: true` + // we need this to prevent overcaching when we fetch the same entity (e.g. project) more than once + // with different set of fields + + if (Object.values(rootQuery).some((value) => value.__ref === key)) { + const mappedEntity = {}; + Object.entries(parsedEntity).forEach(([parsedKey, parsedValue]) => { + if (!parsedValue || typeof parsedValue !== 'object' || parsedValue['__persist']) { + mappedEntity[parsedKey] = parsedValue; + } + }); + obj[key] = mappedEntity; + } else { + obj[key] = parsed[key]; + } + + return obj; + }, mapped); + + return JSON.stringify(mapped); +}; diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 98e45f95b38..c0e923b2670 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,6 +1,7 @@ import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core'; import { BatchHttpLink } from '@apollo/client/link/batch-http'; import { createUploadLink } from 'apollo-upload-client'; +import { persistCacheSync, LocalStorageWrapper } from 'apollo3-cache-persist'; import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; import possibleTypes from '~/graphql_shared/possible_types.json'; @@ -10,6 +11,8 @@ import { objectToQuery, queryToObject } from '~/lib/utils/url_utility'; import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; import { getInstrumentationLink } from './apollo/instrumentation_link'; import { getSuppressNetworkErrorsDuringNavigationLink } from './apollo/suppress_network_errors_during_navigation_link'; +import { getPersistLink } from './apollo/persist_link'; +import { persistenceMapper } from './apollo/persistence_mapper'; export const fetchPolicies = { CACHE_FIRST: 'cache-first', @@ -110,6 +113,7 @@ export default (resolvers = {}, config = {}) => { typeDefs, path = '/api/graphql', useGet = false, + localCacheKey = null, } = config; let ac = null; let uri = `${gon.relative_url_root || ''}${path}`; @@ -201,6 +205,8 @@ export default (resolvers = {}, config = {}) => { }); }); + const persistLink = getPersistLink(); + const appLink = ApolloLink.split( hasSubscriptionOperation, new ActionCableLink(), @@ -212,27 +218,40 @@ export default (resolvers = {}, config = {}) => { performanceBarLink, new StartupJSLink(), apolloCaptchaLink, + persistLink, uploadsLink, requestLink, ].filter(Boolean), ), ); + const newCache = new InMemoryCache({ + ...cacheConfig, + typePolicies: { + ...typePolicies, + ...cacheConfig.typePolicies, + }, + possibleTypes: { + ...possibleTypes, + ...cacheConfig.possibleTypes, + }, + }); + + if (localCacheKey) { + persistCacheSync({ + cache: newCache, + // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode + debug: process.env.NODE_ENV === 'development', + storage: new LocalStorageWrapper(window.localStorage), + persistenceMapper, + }); + } + ac = new ApolloClient({ typeDefs, link: appLink, connectToDevTools: process.env.NODE_ENV !== 'production', - cache: new InMemoryCache({ - ...cacheConfig, - typePolicies: { - ...typePolicies, - ...cacheConfig.typePolicies, - }, - possibleTypes: { - ...possibleTypes, - ...cacheConfig.possibleTypes, - }, - }), + cache: newCache, resolvers, defaultOptions: { query: { diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 241488c8039..9bf382c41e7 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -4,7 +4,7 @@ import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; -import { isFunction, defer, escape } from 'lodash'; +import { isFunction, defer, escape, partial, toLower } from 'lodash'; import Cookies from '~/lib/utils/cookies'; import { SCOPED_LABEL_DELIMITER } from '~/sidebar/components/labels/labels_select_widget/constants'; import { convertToCamelCase, convertToSnakeCase } from './text_utility'; @@ -552,6 +552,22 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => convertObjectProps(convertToCamelCase, obj, options); /** + * This method returns a new object with lowerCase property names + * + * Reasoning for this method is to ensure consistent access for some + * sort of objects + * + * This method also supports additional params in `options` object + * + * @param {Object} obj - Object to be converted. + * @param {Object} options - Object containing additional options. + * @param {boolean} options.deep - FLag to allow deep object converting + * @param {Array[]} options.dropKeys - List of properties to discard while building new object + * @param {Array[]} options.ignoreKeyNames - List of properties to leave intact while building new object + */ +export const convertObjectPropsToLowerCase = partial(convertObjectProps, toLower); + +/** * Converts all the object keys to snake case * * This method also supports additional params in `options` object @@ -717,16 +733,3 @@ export const getFirstPropertyValue = (data) => { return data[key]; }; - -// TODO: remove when FF `new_fonts` is removed https://gitlab.com/gitlab-org/gitlab/-/issues/379147 -/** - * This method checks the FF `new_fonts` - * as well as a query parameter `new_fonts`. - * If either of them is enabled, new fonts will be applied. - * - * @returns Boolean Whether to apply new fonts - */ -export const useNewFonts = () => { - const hasQueryParam = new URLSearchParams(window.location.search).has('new_fonts'); - return window?.gon.features?.newFonts || hasQueryParam; -}; diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index 678ebc35565..61c2ecfecd9 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -9,16 +9,17 @@ export const HTTP_STATUS_PARTIAL_CONTENT = 206; export const HTTP_STATUS_MULTI_STATUS = 207; export const HTTP_STATUS_ALREADY_REPORTED = 208; export const HTTP_STATUS_IM_USED = 226; +export const HTTP_STATUS_BAD_REQUEST = 400; +export const HTTP_STATUS_UNAUTHORIZED = 401; +export const HTTP_STATUS_FORBIDDEN = 403; +export const HTTP_STATUS_NOT_FOUND = 404; export const HTTP_STATUS_METHOD_NOT_ALLOWED = 405; export const HTTP_STATUS_CONFLICT = 409; export const HTTP_STATUS_GONE = 410; export const HTTP_STATUS_PAYLOAD_TOO_LARGE = 413; +export const HTTP_STATUS_IM_A_TEAPOT = 418; export const HTTP_STATUS_UNPROCESSABLE_ENTITY = 422; export const HTTP_STATUS_TOO_MANY_REQUESTS = 429; -export const HTTP_STATUS_BAD_REQUEST = 400; -export const HTTP_STATUS_UNAUTHORIZED = 401; -export const HTTP_STATUS_FORBIDDEN = 403; -export const HTTP_STATUS_NOT_FOUND = 404; export const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500; export const HTTP_STATUS_SERVICE_UNAVAILABLE = 503; diff --git a/app/assets/javascripts/lib/utils/scroll_utils.js b/app/assets/javascripts/lib/utils/scroll_utils.js index 01e43fd3b93..bab84448657 100644 --- a/app/assets/javascripts/lib/utils/scroll_utils.js +++ b/app/assets/javascripts/lib/utils/scroll_utils.js @@ -7,14 +7,11 @@ export const canScroll = () => $(document).height() > $(window).height(); * @returns {Boolean} */ export const isScrolledToBottom = () => { - const $document = $(document); - - const currentPosition = $document.scrollTop(); - const scrollHeight = $document.height(); - - const windowHeight = $(window).height(); + // Use clientHeight to account for any horizontal scrollbar. + const { scrollHeight, scrollTop, clientHeight } = document.documentElement; - return scrollHeight - currentPosition === windowHeight; + // scrollTop can be a float, so round up to next integer. + return Math.ceil(scrollTop + clientHeight) >= scrollHeight; }; /** @@ -31,21 +28,3 @@ export const scrollDown = () => { export const scrollUp = () => { $(document).scrollTop(0); }; - -/** - * Checks if scroll position is in the middle of the page - * @returns {Boolean} - */ -export const isScrolledToMiddle = () => { - const $document = $(document); - const currentPosition = $document.scrollTop(); - const scrollHeight = $document.height(); - const windowHeight = $(window).height(); - - return currentPosition > 0 && scrollHeight - currentPosition !== windowHeight; -}; - -export const toggleDisableButton = ($button, disable) => { - if (disable && $button.prop('disabled')) return; - $button.prop('disabled', disable); -}; diff --git a/app/assets/javascripts/lib/utils/select2_utils.js b/app/assets/javascripts/lib/utils/select2_utils.js deleted file mode 100644 index 03c0e608b79..00000000000 --- a/app/assets/javascripts/lib/utils/select2_utils.js +++ /dev/null @@ -1,25 +0,0 @@ -import axios from './axios_utils'; -import { normalizeHeaders, parseIntPagination } from './common_utils'; - -// This is used in the select2 config to replace jQuery.ajax with axios -export const select2AxiosTransport = (params) => { - axios({ - method: params.type?.toLowerCase() || 'get', - url: params.url, - params: params.data, - }) - .then((res) => { - const results = res.data || []; - const headers = normalizeHeaders(res.headers); - const pagination = parseIntPagination(headers); - const more = pagination.nextPage > pagination.page; - - params.success({ - results, - pagination: { - more, - }, - }); - }) - .catch(params.error); -}; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 3894ec36a0b..05ed08931bb 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -522,15 +522,23 @@ function handleContinueList(e, textArea) { 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 inserted using Japanese IME on MacOS if (compositioningNoteText) return; - const firstSelectedLine = linesFromSelection(textArea).lines[0]; + const selectedLines = linesFromSelection(textArea); + const firstSelectedLine = selectedLines.lines[0]; const listLineMatch = firstSelectedLine.match(LIST_LINE_HEAD_PATTERN); if (listLineMatch) { const { leader, indent, content, isOl } = listLineMatch.groups; const emptyListItem = !content; + const prefixLength = leader.length + indent.length; + + if (selectedLines.selectionStart - selectedLines.startPos < prefixLength) { + // cursor in the indent/leader area, allow the natural line feed to be added + return; + } if (emptyListItem) { // erase empty list item - select the text and allow the diff --git a/app/assets/javascripts/listbox/index.js b/app/assets/javascripts/listbox/index.js index 7e8fc4b637b..e3d26d1464e 100644 --- a/app/assets/javascripts/listbox/index.js +++ b/app/assets/javascripts/listbox/index.js @@ -1,22 +1,20 @@ import { GlCollapsibleListbox } from '@gitlab/ui'; import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; export function parseAttributes(el) { - const { items: itemsString, selected, right: rightString } = el.dataset; + const { items: itemsString, selected, placement } = el.dataset; const items = JSON.parse(itemsString); - const right = parseBoolean(rightString); const { className } = el; - return { items, selected, right, className }; + return { items, selected, placement, className }; } export function initListbox(el, { onChange } = {}) { if (!el) return null; - const { items, selected, right, className } = parseAttributes(el); + const { items, selected, placement, className } = parseAttributes(el); return new Vue({ el, @@ -34,7 +32,7 @@ export function initListbox(el, { onChange } = {}) { return h(GlCollapsibleListbox, { props: { items, - right, + placement, selected: this.selected, toggleText: this.text, }, diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index c1afabf1e35..600654794a5 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -6,8 +6,17 @@ const GITLAB_FALLBACK_LANGUAGE = 'en'; const languageCode = () => document.querySelector('html').getAttribute('lang') || GITLAB_FALLBACK_LANGUAGE; -const locale = new Jed(window.translations || {}); -delete window.translations; + +/** + * This file might be imported into a web worker indirectly, the `window` object + * won't be defined in the web worker context so we need to check if it is defined + * before we access the `translations` property. + */ +const hasTranslations = typeof window !== 'undefined' && window.translations; +const locale = new Jed(hasTranslations ? window.translations : {}); +if (hasTranslations) { + delete window.translations; +} /** Translates `text` diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index fd5c4abe729..4c715c4993f 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -16,7 +16,6 @@ import * as tooltips from '~/tooltips'; import { initPrefetchLinks } from '~/lib/utils/navigation_utility'; import { logHelloDeferred } from 'jh_else_ce/lib/logger/hello_deferred'; import initAlertHandler from './alert_handler'; -import { addDismissFlashClickListener } from './flash'; import initTodoToggle from './header'; import initLayoutNav from './layout_nav'; import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils'; @@ -253,16 +252,6 @@ $('form.filter-form').on('submit', function filterFormSubmitCallback(event) { visitUrl(action); }); -const flashContainer = document.querySelector('.flash-container'); - -if (flashContainer && flashContainer.children.length) { - flashContainer - .querySelectorAll('.flash-alert, .flash-notice, .flash-success') - .forEach((flashEl) => { - addDismissFlashClickListener(flashEl); - }); -} - // initialize field errors $('.gl-show-field-errors').each((i, form) => new GlFieldErrors(form)); diff --git a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue index 90034f46e7c..88d5384c9d5 100644 --- a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue @@ -39,6 +39,7 @@ export default { v-gl-tooltip.hover :title="$options.title" :aria-label="$options.title" + data-qa-selector="approve_access_request_button" icon="check" type="submit" /> diff --git a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue index 24500fbe44d..3b4b7516934 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue @@ -35,7 +35,7 @@ export default { :title="$options.i18n.buttonTitle" :aria-label="$options.i18n.buttonTitle" icon="remove" - data-qa-selector="delete_group_access_link" + data-qa-selector="remove_group_link_button" @click="showRemoveGroupLinkModal(groupLink)" /> </template> diff --git a/app/assets/javascripts/members/components/action_dropdowns/constants.js b/app/assets/javascripts/members/components/action_dropdowns/constants.js index 8ccfc57dc28..ce6865a8f0a 100644 --- a/app/assets/javascripts/members/components/action_dropdowns/constants.js +++ b/app/assets/javascripts/members/components/action_dropdowns/constants.js @@ -19,4 +19,5 @@ export const I18N = { lastGroupOwnerCannotBeRemoved: s__( 'Members|A group must have at least one owner. To remove the member, assign a new owner.', ), + banMember: s__('Members|Ban member'), }; diff --git a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue index 8f5c32956a2..c82ebadea6e 100644 --- a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue +++ b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue @@ -20,9 +20,11 @@ export default { 'ee_component/members/components/action_dropdowns/disable_two_factor_dropdown_item.vue' ), LdapOverrideDropdownItem: () => - import('ee_component/members/components/ldap/ldap_override_dropdown_item.vue'), + import('ee_component/members/components/action_dropdowns/ldap_override_dropdown_item.vue'), LeaveGroupDropdownItem, RemoveMemberDropdownItem, + BanMemberDropdownItem: () => + import('ee_component/members/components/action_dropdowns/ban_member_dropdown_item.vue'), }, directives: { GlTooltip: GlTooltipDirective, @@ -77,7 +79,10 @@ export default { }, showDropdown() { return ( - this.permissions.canDisableTwoFactor || this.showLeaveOrRemove || this.showLdapOverride + this.permissions.canDisableTwoFactor || + this.showLeaveOrRemove || + this.showLdapOverride || + this.showBan ); }, showLeaveOrRemove() { @@ -86,6 +91,9 @@ export default { showLdapOverride() { return this.permissions.canOverride && !this.member.isOverridden; }, + showBan() { + return !this.isCurrentUser && this.permissions.canBan; + }, }, }; </script> @@ -130,5 +138,8 @@ export default { <ldap-override-dropdown-item v-else-if="showLdapOverride" :member="member">{{ $options.i18n.editPermissions }}</ldap-override-dropdown-item> + <ban-member-dropdown-item v-if="showBan" :member="member">{{ + $options.i18n.banMember + }}</ban-member-dropdown-item> </gl-dropdown> </template> diff --git a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue index b179ced46e1..b28ca6e385b 100644 --- a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue @@ -14,6 +14,7 @@ export default { text: s__('Members|Remove group'), attributes: { variant: 'danger', + 'data-qa-selector': 'remove_group_button', }, }, csrf, diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue index 337379d8b4e..f1da1cd8ffc 100644 --- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue @@ -70,6 +70,7 @@ export default { text: this.actionText, attributes: { variant: 'danger', + 'data-qa-selector': 'remove_member_button', }, }; }, diff --git a/app/assets/javascripts/members/components/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_actions.vue index 6ec7be608ba..61a6f37687a 100644 --- a/app/assets/javascripts/members/components/table/member_action_buttons.vue +++ b/app/assets/javascripts/members/components/table/member_actions.vue @@ -6,7 +6,7 @@ import InviteActionButtons from '../action_buttons/invite_action_buttons.vue'; import UserActionDropdown from '../action_dropdowns/user_action_dropdown.vue'; export default { - name: 'MemberActionButtons', + name: 'MemberActions', components: { UserActionDropdown, GroupActionButtons, diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 8f03a298e63..c973d58fcd2 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -26,7 +26,7 @@ import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue'; import RemoveMemberModal from '../modals/remove_member_modal.vue'; import CreatedAt from './created_at.vue'; import ExpirationDatepicker from './expiration_datepicker.vue'; -import MemberActionButtons from './member_action_buttons.vue'; +import MemberActions from './member_actions.vue'; import MemberAvatar from './member_avatar.vue'; import MemberSource from './member_source.vue'; import MemberActivity from './member_activity.vue'; @@ -42,7 +42,7 @@ export default { CreatedAt, MembersTableCell, MemberSource, - MemberActionButtons, + MemberActions, RoleDropdown, RemoveGroupLinkModal, RemoveMemberModal, @@ -51,7 +51,7 @@ export default { DisableTwoFactorModal: () => import('ee_component/members/components/modals/disable_two_factor_modal.vue'), LdapOverrideConfirmationModal: () => - import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'), + import('ee_component/members/components/modals/ldap_override_confirmation_modal.vue'), }, inject: ['namespace', 'currentUserId', 'canManageMembers'], props: { @@ -135,7 +135,10 @@ export default { tbodyTrAttr(member) { return { ...this.tableAttrs.tr, - ...(member?.id && { 'data-testid': `members-table-row-${member.id}` }), + ...(member?.id && { + 'data-testid': `members-table-row-${member.id}`, + 'data-qa-selector': 'member_row', + }), }; }, paginationLinkGenerator(page) { @@ -299,7 +302,7 @@ export default { <template #cell(actions)="{ item: member }"> <members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member"> - <member-action-buttons + <member-actions :member-type="memberType" :is-current-user="isCurrentUser" :permissions="permissions" diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index 70808587d56..e066b023fbb 100644 --- a/app/assets/javascripts/members/components/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -11,7 +11,8 @@ export default { components: { GlDropdown, GlDropdownItem, - LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'), + LdapDropdownItem: () => + import('ee_component/members/components/action_dropdowns/ldap_dropdown_item.vue'), }, inject: ['namespace', 'group'], props: { diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 80eb94a5364..61abdca0a5b 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -95,10 +95,6 @@ MergeRequest.prototype.initMRBtnListeners = function () { .then(({ data }) => { draftToggle.removeAttribute('disabled'); - if (!window.gon?.features?.realtimeMrStatusChange) { - eventHub.$emit('MRWidgetUpdateRequested'); - } - MergeRequest.toggleDraftStatus(data.title, wipEvent === 'ready'); }) .catch(() => { @@ -155,6 +151,10 @@ MergeRequest.hideCloseButton = function () { }; MergeRequest.toggleDraftStatus = function (title, isReady) { + if (!window.gon?.features?.realtimeMrStatusChange) { + eventHub.$emit('MRWidgetUpdateRequested'); + } + if (isReady) { toast(__('Marked as ready. Merging is now allowed.')); } else { diff --git a/app/assets/javascripts/merge_requests/components/compare_app.vue b/app/assets/javascripts/merge_requests/components/compare_app.vue new file mode 100644 index 00000000000..8e02048f494 --- /dev/null +++ b/app/assets/javascripts/merge_requests/components/compare_app.vue @@ -0,0 +1,134 @@ +<script> +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import axios from '~/lib/utils/axios_utils'; +import CompareDropdown from '~/merge_requests/components/compare_dropdown.vue'; + +export default { + components: { + GlIcon, + GlLoadingIcon, + CompareDropdown, + }, + directives: { + SafeHtml, + }, + inject: { + projectsPath: { + default: '', + }, + branchCommitPath: { + default: '', + }, + currentProject: { + default: () => ({}), + }, + currentBranch: { + default: () => ({}), + }, + inputs: { + default: () => ({}), + }, + i18n: { + default: () => ({}), + }, + toggleClass: { + default: () => ({}), + }, + branchQaSelector: { + default: '', + }, + }, + data() { + return { + selectedProject: this.currentProject, + selectedBranch: this.currentBranch, + loading: false, + commitHtml: null, + }; + }, + computed: { + staticProjectData() { + if (this.projectsPath) return undefined; + + return [this.currentProject]; + }, + showCommitBox() { + return this.commitHtml || this.loading || !this.selectedBranch.value; + }, + }, + mounted() { + this.fetchCommit(); + }, + methods: { + selectProject(p) { + this.selectedProject = p; + }, + selectBranch(branch) { + this.selectedBranch = branch; + this.fetchCommit(); + }, + async fetchCommit() { + if (!this.selectedBranch.value) return; + + this.loading = true; + + const { data } = await axios.get(this.branchCommitPath, { + params: { target_project_id: this.selectedProject.value, ref: this.selectedBranch.value }, + }); + + this.loading = false; + this.commitHtml = data; + }, + }, +}; +</script> + +<template> + <div> + <div class="clearfix"> + <div class="merge-request-select gl-pl-0"> + <compare-dropdown + :static-data="staticProjectData" + :endpoint="projectsPath" + :default="currentProject" + :dropdown-header="i18n.projectHeaderText" + :input-id="inputs.project.id" + :input-name="inputs.project.name" + :toggle-class="toggleClass.project" + is-project + @selected="selectProject" + /> + </div> + <div class="merge-request-select merge-request-branch-select gl-pr-0"> + <compare-dropdown + :endpoint="selectedProject.refsUrl" + :dropdown-header="i18n.branchHeaderText" + :input-id="inputs.branch.id" + :input-name="inputs.branch.name" + :default="currentBranch" + :toggle-class="toggleClass.branch" + :qa-selector="branchQaSelector" + @selected="selectBranch" + /> + </div> + </div> + <div + v-if="showCommitBox" + class="gl-bg-gray-50 gl-rounded-base gl-my-4" + data-testid="commit-box" + > + <gl-loading-icon v-if="loading" class="gl-py-3" /> + <template v-else> + <div + v-if="!selectedBranch.value" + class="compare-commit-empty gl-display-flex gl-align-items-center gl-p-5" + > + <gl-icon name="branch" class="gl-mr-3" /> + {{ __('Select a branch to compare') }} + </div> + <ul v-safe-html="commitHtml" class="list-unstyled mr_source_commit"></ul> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue new file mode 100644 index 00000000000..1590e693c07 --- /dev/null +++ b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue @@ -0,0 +1,145 @@ +<script> +import { GlListbox } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { createAlert } from '~/flash'; +import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; + +export default { + components: { + GlListbox, + }, + props: { + staticData: { + type: Array, + required: false, + default: () => [], + }, + endpoint: { + type: String, + required: false, + default: '', + }, + default: { + type: Object, + required: true, + }, + dropdownHeader: { + type: String, + required: true, + }, + isProject: { + type: Boolean, + required: false, + default: false, + }, + inputId: { + type: String, + required: true, + }, + inputName: { + type: String, + required: true, + }, + toggleClass: { + type: String, + required: false, + default: '', + }, + qaSelector: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + current: this.default, + selected: this.default.value, + isLoading: false, + data: this.staticData, + searchStr: '', + }; + }, + computed: { + filteredData() { + if (this.endpoint) return this.data; + + return this.data.filter( + (d) => d.text.toLowerCase().indexOf(this.searchStr.toLowerCase()) >= 0, + ); + }, + }, + methods: { + async fetchData() { + if (!this.endpoint) return; + + this.isLoading = true; + + try { + const { data } = await axios.get(this.endpoint, { + params: { search: this.searchStr }, + }); + + if (this.isProject) { + this.data = data.map((p) => ({ + value: `${p.id}`, + text: p.full_path.replace(/^\//, ''), + refsUrl: p.refs_url, + })); + } else { + this.data = data.Branches.map((d) => ({ + value: d, + text: d, + })); + } + + this.isLoading = false; + } catch { + createAlert({ + message: __('Error fetching data. Please try again.'), + primaryButton: { text: __('Try again'), clickHandler: () => this.fetchData() }, + }); + } + }, + searchData: debounce(function searchData(search) { + this.searchStr = search; + this.fetchData(); + }, 500), + selectItem(id) { + this.current = this.data.find((d) => d.value === id); + + this.$emit('selected', this.current); + }, + }, +}; +</script> + +<template> + <div> + <input + :id="inputId" + type="hidden" + :value="current.value" + :name="inputName" + data-testid="target-project-input" + /> + <gl-listbox + v-model="selected" + :items="filteredData" + :toggle-text="current.text || dropdownHeader" + :header-text="dropdownHeader" + :searching="isLoading" + searchable + class="gl-w-full dropdown-target-project" + :toggle-class="[ + 'gl-align-items-flex-start! gl-justify-content-start! mr-compare-dropdown', + toggleClass, + ]" + :data-qa-selector="qaSelector" + @shown="fetchData" + @search="searchData" + @select="selectItem" + /> + </div> +</template> diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index 6af1baaa37e..525094271d9 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -2,7 +2,7 @@ import { GlIntersectionObserver, GlLink, GlSprintf, GlBadge } from '@gitlab/ui'; import { mapGetters, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import { TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { isLoggedIn } from '~/lib/utils/common_utils'; @@ -45,7 +45,7 @@ export default { doneFetchingBatchDiscussions: (state) => state.notes.doneFetchingBatchDiscussions, }), issuableId() { - return convertToGraphQLId(TYPE_MERGE_REQUEST, this.getNoteableData.id); + return convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.getNoteableData.id); }, issuableIid() { return `${this.getNoteableData.iid}`; @@ -77,7 +77,7 @@ export default { <template> <gl-intersection-observer - class="gl-relative gl-top-2" + class="gl-relative gl-top-n5" @appear="setStickyHeaderVisible(false)" @disappear="setStickyHeaderVisible(true)" > diff --git a/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue b/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue deleted file mode 100644 index cd2e25793f4..00000000000 --- a/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue +++ /dev/null @@ -1,87 +0,0 @@ -<script> -import { GlListbox } from '@gitlab/ui'; -import { debounce } from 'lodash'; -import { createAlert } from '~/flash'; -import { __ } from '~/locale'; -import axios from '~/lib/utils/axios_utils'; - -export default { - components: { - GlListbox, - }, - inject: { - targetProjectsPath: { - type: String, - required: true, - }, - currentProject: { - type: Object, - required: true, - }, - }, - data() { - return { - currentProject: this.currentProject, - selected: this.currentProject.value, - isLoading: false, - projects: [], - }; - }, - methods: { - async fetchProjects(search = '') { - this.isLoading = true; - - try { - const { data } = await axios.get(this.targetProjectsPath, { - params: { search }, - }); - - this.projects = data.map((p) => ({ - value: `${p.id}`, - text: p.full_path.replace(/^\//, ''), - refsUrl: p.refs_url, - })); - this.isLoading = false; - } catch { - createAlert({ - message: __('Error fetching target projects. Please try again.'), - primaryButton: { text: __('Try again'), clickHandler: () => this.fetchProjects(search) }, - }); - } - }, - searchProjects: debounce(function searchProjects(search) { - this.fetchProjects(search); - }, 500), - selectProject(projectId) { - this.currentProject = this.projects.find((p) => p.value === projectId); - - this.$emit('project-selected', this.currentProject.refsUrl); - }, - }, -}; -</script> - -<template> - <div> - <input - id="merge_request_target_project_id" - type="hidden" - :value="currentProject.value" - name="merge_request[target_project_id]" - data-testid="target-project-input" - /> - <gl-listbox - v-model="selected" - :items="projects" - :toggle-text="currentProject.text" - :header-text="__('Select target project')" - :searching="isLoading" - searchable - class="gl-w-full dropdown-target-project" - toggle-class="gl-align-items-flex-start! gl-justify-content-start! mr-compare-dropdown js-target-project" - @shown="fetchProjects" - @search="searchProjects" - @select="selectProject" - /> - </div> -</template> diff --git a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue index 3a13c123d77..4b3c1bd7d10 100644 --- a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue +++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue @@ -2,7 +2,7 @@ import { GlSprintf, GlModal } from '@gitlab/ui'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; - +import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import { __, n__, s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; @@ -84,7 +84,7 @@ Once deleted, it cannot be undone or recovered.`), successful: false, }); - if (error.response && error.response.status === 404) { + if (error.response && error.response.status === HTTP_STATUS_NOT_FOUND) { createAlert({ message: sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), { milestoneTitle: this.milestoneTitle, diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js index 3b7e5a5f2ee..037120a0d81 100644 --- a/app/assets/javascripts/mirrors/ssh_mirror.js +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -3,6 +3,7 @@ import { escape } from 'lodash'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { backOff } from '~/lib/utils/common_utils'; +import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; import { __ } from '~/locale'; import AUTH_METHOD from './constants'; @@ -87,7 +88,7 @@ export default class SSHMirror { )}`, ) .then(({ data, status }) => { - if (status === 204) { + if (status === HTTP_STATUS_NO_CONTENT) { this.backOffRequestCounter += 1; if (this.backOffRequestCounter < 3) { next(); diff --git a/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue b/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue deleted file mode 100644 index 42f6394ed68..00000000000 --- a/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue +++ /dev/null @@ -1,48 +0,0 @@ -<script> -import { GlAlert, GlLink } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - i18n: { - titleLabel: __('Machine Learning Experiment Tracking is in Incubating Phase'), - contentLabel: __( - 'GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited', - ), - learnMoreLabel: __('Learn more'), - feedbackLabel: __('Feedback'), - }, - name: 'MlopsIncubationAlert', - components: { GlAlert, GlLink }, - data() { - return { - isAlertDismissed: false, - }; - }, - computed: { - shouldShowAlert() { - return !this.isAlertDismissed; - }, - }, - methods: { - dismissAlert() { - this.isAlertDismissed = true; - }, - }, -}; -</script> - -<template> - <gl-alert - v-if="shouldShowAlert" - :title="$options.i18n.titleLabel" - variant="warning" - :primary-button-text="$options.i18n.feedbackLabel" - primary-button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/381660" - @dismiss="dismissAlert" - > - {{ $options.i18n.contentLabel }} - <gl-link href="https://about.gitlab.com/handbook/engineering/incubation/" target="_blank">{{ - $options.i18n.learnMoreLabel - }}</gl-link> - </gl-alert> -</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue index 0bb2a913dec..d0c42905ee2 100644 --- a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue +++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue @@ -1,7 +1,8 @@ <script> import { GlLink } from '@gitlab/ui'; import { __ } from '~/locale'; -import IncubationAlert from './incubation_alert.vue'; +import { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants'; +import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; export default { name: 'MlCandidate', @@ -9,7 +10,12 @@ export default { IncubationAlert, GlLink, }, - inject: ['candidate'], + props: { + candidate: { + type: Object, + required: true, + }, + }, i18n: { titleLabel: __('Model candidate details'), infoLabel: __('Info'), @@ -39,12 +45,17 @@ export default { ]; }, }, + FEATURE_NAME, + FEATURE_FEEDBACK_ISSUE, }; </script> <template> <div> - <incubation-alert /> + <incubation-alert + :feature-name="$options.FEATURE_NAME" + :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE" + /> <h3> {{ $options.i18n.titleLabel }} diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue index 5d13122765a..c09aabb0d40 100644 --- a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue +++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue @@ -1,9 +1,20 @@ <script> -import { GlTable, GlLink, GlPagination, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { getParameterValues, setUrlParams } from '~/lib/utils/url_utility'; +import { GlTable, GlLink, GlTooltipDirective } from '@gitlab/ui'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import IncubationAlert from './incubation_alert.vue'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + LIST_KEY_CREATED_AT, + BASE_SORT_FIELDS, + METRIC_KEY_PREFIX, + FEATURE_NAME, + FEATURE_FEEDBACK_ISSUE, +} from '~/ml/experiment_tracking/constants'; +import { s__ } from '~/locale'; +import { queryToObject, setUrlParams, visitUrl } from '~/lib/utils/url_utility'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import KeysetPagination from '~/vue_shared/components/incubation/pagination.vue'; +import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; export default { name: 'MlExperiment', @@ -12,19 +23,36 @@ export default { GlLink, TimeAgo, IncubationAlert, - GlPagination, + RegistrySearch, + KeysetPagination, }, directives: { GlTooltip: GlTooltipDirective, }, - inject: ['candidates', 'metricNames', 'paramNames', 'pagination'], + inject: ['candidates', 'metricNames', 'paramNames', 'pageInfo'], data() { + const query = queryToObject(window.location.search); + + const filter = query.name ? [{ value: { data: query.name }, type: FILTERED_SEARCH_TERM }] : []; + + let orderBy = query.orderBy || LIST_KEY_CREATED_AT; + + if (query.orderByType === 'metric') { + orderBy = `${METRIC_KEY_PREFIX}${orderBy}`; + } + return { - page: parseInt(getParameterValues('page')[0], 10) || 1, + filters: filter, + sorting: { + orderBy, + sort: (query.sort || 'desc').toLowerCase(), + }, }; }, computed: { fields() { + if (this.candidates.length === 0) return []; + return [ { key: 'name', label: this.$options.i18n.nameLabel }, { key: 'created_at', label: this.$options.i18n.createdAtLabel }, @@ -38,39 +66,87 @@ export default { displayPagination() { return this.candidates.length > 0; }, - prevPage() { - return this.pagination.page > 1 ? this.pagination.page - 1 : null; + sortableFields() { + return [ + ...BASE_SORT_FIELDS, + ...this.metricNames.map((name) => ({ + orderBy: `${METRIC_KEY_PREFIX}${name}`, + label: capitalizeFirstCharacter(name), + })), + ]; }, - nextPage() { - return !this.pagination.isLastPage ? this.pagination.page + 1 : null; + parsedQuery() { + const name = this.filters + .map((f) => f.value.data) + .join(' ') + .trim(); + + const filterByQuery = name === '' ? {} : { name }; + + let orderByType = 'column'; + let { orderBy } = this.sorting; + const { sort } = this.sorting; + + if (orderBy.startsWith(METRIC_KEY_PREFIX)) { + orderBy = this.sorting.orderBy.slice(METRIC_KEY_PREFIX.length); + orderByType = 'metric'; + } + + return { ...filterByQuery, orderBy, orderByType, sort }; }, }, methods: { - generateLink(page) { - return setUrlParams({ page }); + submitFilters() { + return visitUrl(setUrlParams({ ...this.parsedQuery })); + }, + updateFilters(newValue) { + this.filters = newValue; + }, + updateSorting(newValue) { + this.sorting = { ...this.sorting, ...newValue }; + }, + updateSortingAndEmitUpdate(newValue) { + this.updateSorting(newValue); + this.submitFilters(); }, }, i18n: { - titleLabel: __('Experiment candidates'), - emptyStateLabel: __('This experiment has no logged candidates'), - artifactsLabel: __('Artifacts'), - detailsLabel: __('Details'), - userLabel: __('User'), - createdAtLabel: __('Created at'), - nameLabel: __('Name'), - noDataContent: __('-'), + titleLabel: s__('MlExperimentTracking|Experiment candidates'), + emptyStateLabel: s__('MlExperimentTracking|No candidates to display'), + artifactsLabel: s__('MlExperimentTracking|Artifacts'), + detailsLabel: s__('MlExperimentTracking|Details'), + userLabel: s__('MlExperimentTracking|User'), + createdAtLabel: s__('MlExperimentTracking|Created at'), + nameLabel: s__('MlExperimentTracking|Name'), + noDataContent: s__('MlExperimentTracking|-'), + filterCandidatesLabel: s__('MlExperimentTracking|Filter candidates'), }, + FEATURE_NAME, + FEATURE_FEEDBACK_ISSUE, }; </script> <template> <div> - <incubation-alert /> + <incubation-alert + :feature-name="$options.FEATURE_NAME" + :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE" + /> <h3> {{ $options.i18n.titleLabel }} </h3> + <registry-search + :filters="filters" + :sorting="sorting" + :sortable-fields="sortableFields" + @sorting:changed="updateSortingAndEmitUpdate" + @filter:changed="updateFilters" + @filter:submit="submitFilters" + @filter:clear="filters = []" + /> + <gl-table :fields="fields" :items="candidates" @@ -119,16 +195,6 @@ export default { </template> </gl-table> - <gl-pagination - v-if="displayPagination" - v-model="pagination.page" - :prev-page="prevPage" - :next-page="nextPage" - :total-items="pagination.totalItems" - :per-page="pagination.perPage" - :link-gen="generateLink" - align="center" - class="w-100" - /> + <keyset-pagination v-if="displayPagination" v-bind="pageInfo" /> </div> </template> diff --git a/app/assets/javascripts/ml/experiment_tracking/constants.js b/app/assets/javascripts/ml/experiment_tracking/constants.js new file mode 100644 index 00000000000..15462b519e1 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/constants.js @@ -0,0 +1,22 @@ +import { __, s__ } from '~/locale'; + +export const METRIC_KEY_PREFIX = 'metric.'; + +export const LIST_KEY_CREATED_AT = 'created_at'; + +export const BASE_SORT_FIELDS = Object.freeze([ + { + orderBy: 'name', + label: __('Name'), + }, + { + orderBy: LIST_KEY_CREATED_AT, + label: __('Created at'), + }, +]); + +export const EMPTY_STATE_SVG = '/assets/illustrations/empty-state/empty-dag-md.svg'; + +export const FEATURE_NAME = s__('MlExperimentTracking|Machine learning experiment tracking'); + +export const FEATURE_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/381660'; diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue new file mode 100644 index 00000000000..4f2b8db3c00 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue @@ -0,0 +1,85 @@ +<script> +import { GlTableLite, GlEmptyState, GlLink } from '@gitlab/ui'; +import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; +import Pagination from '~/vue_shared/components/incubation/pagination.vue'; +import { + FEATURE_NAME, + FEATURE_FEEDBACK_ISSUE, + EMPTY_STATE_SVG, +} from '~/ml/experiment_tracking/constants'; +import * as constants from '~/ml/experiment_tracking/routes/experiments/index/constants'; +import * as translations from '~/ml/experiment_tracking/routes/experiments/index/translations'; + +export default { + name: 'MlExperimentsIndexApp', + components: { + Pagination, + IncubationAlert, + GlTableLite, + GlEmptyState, + GlLink, + }, + props: { + experiments: { + type: Array, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + }, + tableFields: constants.EXPERIMENTS_TABLE_FIELDS, + i18n: translations, + computed: { + hasExperiments() { + return this.experiments.length > 0; + }, + tableItems() { + return this.experiments.map((exp) => ({ + nameColumn: { name: exp.name, path: exp.path }, + candidateCountColumn: exp.candidate_count, + })); + }, + }, + constants: { + FEATURE_NAME, + FEATURE_FEEDBACK_ISSUE, + EMPTY_STATE_SVG, + ...constants, + }, +}; +</script> + +<template> + <div v-if="hasExperiments"> + <h1 class="page-title gl-font-size-h-display"> + {{ $options.i18n.TITLE_LABEL }} + </h1> + + <incubation-alert + :feature-name="$options.constants.FEATURE_NAME" + :link-to-feedback-issue="$options.constants.FEATURE_FEEDBACK_ISSUE" + /> + + <gl-table-lite :items="tableItems" :fields="$options.tableFields"> + <template #cell(nameColumn)="data"> + <gl-link :href="data.value.path"> + {{ data.value.name }} + </gl-link> + </template> + </gl-table-lite> + + <pagination v-if="hasExperiments" v-bind="pageInfo" /> + </div> + + <gl-empty-state + v-else + :title="$options.i18n.EMPTY_STATE_TITLE_LABEL" + :primary-button-text="$options.i18n.CREATE_NEW_LABEL" + :primary-button-link="$options.constants.CREATE_EXPERIMENT_HELP_PATH" + :svg-path="$options.constants.EMPTY_STATE_SVG" + :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL" + class="gl-py-8" + /> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js new file mode 100644 index 00000000000..3026bce0972 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js @@ -0,0 +1,17 @@ +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export const CREATE_EXPERIMENT_HELP_PATH = helpPagePath( + 'user/project/ml/experiment_tracking/index.md', + { + anchor: 'tracking-new-experiments-and-trials', + }, +); + +export const EXPERIMENTS_TABLE_FIELDS = Object.freeze([ + { key: 'nameColumn', label: s__('MlExperimentTracking|Experiment') }, + { + key: 'candidateCountColumn', + label: s__('MlExperimentTracking|Logged candidates for experiment'), + }, +]); diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/index.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/index.js new file mode 100644 index 00000000000..b40735ebe22 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/index.js @@ -0,0 +1,3 @@ +import MlExperimentsIndex from './components/ml_experiments_index.vue'; + +export default MlExperimentsIndex; diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js new file mode 100644 index 00000000000..e954c054cf5 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js @@ -0,0 +1,11 @@ +import { s__ } from '~/locale'; + +export const TITLE_LABEL = s__('MlExperimentTracking|Model experiments'); + +export const CREATE_NEW_LABEL = s__('MlExperimentTracking|Create a new experiment'); + +export const EMPTY_STATE_TITLE_LABEL = s__('MlExperimentTracking|No experiments'); + +export const EMPTY_STATE_DESCRIPTION_LABEL = s__( + 'MlExperimentTracking|There are no logged experiments for this project. Create a new experiment using the MLflow client.', +); diff --git a/app/assets/javascripts/mr_notes/init.js b/app/assets/javascripts/mr_notes/init.js index aab3c41b4cf..79447bc115d 100644 --- a/app/assets/javascripts/mr_notes/init.js +++ b/app/assets/javascripts/mr_notes/init.js @@ -20,7 +20,6 @@ function setupMrNotesState(notesDataset) { store.dispatch('setUserData', currentUserData); store.dispatch('setTargetNoteHash', getLocationHash()); store.dispatch('setEndpoints', endpoints); - eventHub.$once('fetchNotesData', () => store.dispatch('fetchNotes')); } export function initMrStateLazyLoad() { @@ -35,10 +34,13 @@ export function initMrStateLazyLoad() { stop = store.watch( (state) => state.page.activeTab, (activeTab) => { + setupMrNotesState(notesDataset); + // prevent loading MR state on commits and pipelines pages // this is due to them having a shared controller with the Overview page if (['diffs', 'show'].includes(activeTab)) { - setupMrNotesState(notesDataset); + eventHub.$once('fetchNotesData', () => store.dispatch('fetchNotes')); + requestIdleCallback(() => { initReviewBar(); initOverviewTabCounter(); diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index f5f10aa4a9b..d968c125068 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -23,6 +23,7 @@ export default () => { } const notesFilterProps = getNotesFilterData(el); + const notesDataset = el.dataset; // eslint-disable-next-line no-new new Vue({ @@ -32,8 +33,10 @@ export default () => { NotesApp, }, store, + provide: { + reportAbusePath: notesDataset.reportAbusePath, + }, data() { - const notesDataset = el.dataset; const noteableData = JSON.parse(notesDataset.noteableData); noteableData.noteableType = notesDataset.noteableType; noteableData.targetType = notesDataset.targetType; diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue index 7b0076cc5d4..da22a8d2fb7 100644 --- a/app/assets/javascripts/nav/components/new_nav_toggle.vue +++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue @@ -45,7 +45,7 @@ export default { Tracking.event(undefined, 'click_toggle', { label: this.enabled ? 'disable_new_nav_beta' : 'enable_new_nav_beta', - property: 'navigation', + property: this.enabled ? 'navigation' : 'navigation_top', }); window.location.reload(); diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue index e55bf25a60c..ab9313f7041 100644 --- a/app/assets/javascripts/nav/components/top_nav_app.vue +++ b/app/assets/javascripts/nav/components/top_nav_app.vue @@ -24,7 +24,7 @@ export default { trackToggleEvent() { Tracking.event(undefined, 'click_nav', { label: 'hamburger_menu', - property: 'top_navigation', + property: 'navigation_top', }); }, }, diff --git a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue index 97856eaf256..0f069670d09 100644 --- a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue +++ b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue @@ -76,7 +76,11 @@ export default { :class="menuClass" data-testid="menu-sidebar" > - <top-nav-menu-sections :sections="menuSections" @menu-item-click="onMenuItemClick" /> + <top-nav-menu-sections + :sections="menuSections" + :is-primary-section="true" + @menu-item-click="onMenuItemClick" + /> </div> <keep-alive-slots v-show="activeView" diff --git a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue index 97e63c7324e..1f3f11dc624 100644 --- a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue +++ b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue @@ -1,7 +1,7 @@ <script> import TopNavMenuItem from './top_nav_menu_item.vue'; -const BORDER_CLASSES = 'gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-50'; +const BORDER_CLASSES = 'gl-pt-3 gl-border-1 gl-border-t-solid'; export default { components: { @@ -17,6 +17,11 @@ export default { required: false, default: false, }, + isPrimarySection: { + type: Boolean, + required: false, + default: false, + }, }, methods: { onClick(menuItem) { @@ -30,8 +35,11 @@ export default { getMenuSectionClasses(index) { // This is a method instead of a computed so we don't have to incur the cost of // creating a whole new array/object. + const hasBorder = this.withTopBorder || index > 0; return { - [BORDER_CLASSES]: this.withTopBorder || index > 0, + [BORDER_CLASSES]: hasBorder, + 'gl-border-gray-100': hasBorder && this.isPrimarySection, + 'gl-border-gray-50': hasBorder && !this.isPrimarySection, 'gl-mt-3': index > 0, }; }, diff --git a/app/assets/javascripts/notes/components/attachments_warning.vue b/app/assets/javascripts/notes/components/attachments_warning.vue new file mode 100644 index 00000000000..aaa4b0d92b9 --- /dev/null +++ b/app/assets/javascripts/notes/components/attachments_warning.vue @@ -0,0 +1,18 @@ +<script> +import { COMMENT_FORM } from '../i18n'; + +export default { + i18n: COMMENT_FORM.attachmentMsg, + data() { + return { + message: this.$options.i18n, + }; + }, +}; +</script> + +<template> + <div class="issuable-note-warning" data-testid="attachment-warning"> + {{ message }} + </div> +</template> diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue index 84bda1b0b5c..cc372520c70 100644 --- a/app/assets/javascripts/notes/components/comment_field_layout.vue +++ b/app/assets/javascripts/notes/components/comment_field_layout.vue @@ -1,14 +1,18 @@ <script> +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; import EmailParticipantsWarning from './email_participants_warning.vue'; +import AttachmentsWarning from './attachments_warning.vue'; const DEFAULT_NOTEABLE_TYPE = 'Issue'; export default { components: { + AttachmentsWarning, EmailParticipantsWarning, NoteableWarning, }, + mixins: [glFeatureFlagsMixin()], props: { noteableData: { type: Object, @@ -29,6 +33,11 @@ export default { required: false, default: false, }, + containsLink: { + type: Boolean, + required: false, + default: false, + }, }, computed: { isLocked() { @@ -46,6 +55,13 @@ export default { showEmailParticipantsWarning() { return this.emailParticipants.length && !this.isInternalNote; }, + showAttachmentWarning() { + return ( + this.glFeatures.serviceDeskNewNoteEmailNativeAttachments && + this.showEmailParticipantsWarning && + this.containsLink + ); + }, }, }; </script> @@ -68,6 +84,7 @@ export default { :confidential-noteable-docs-path="noteableData.confidential_issues_docs_path" /> <slot></slot> + <attachments-warning v-if="showAttachmentWarning" /> <email-participants-warning v-if="showEmailParticipantsWarning" class="gl-border-t-1 gl-border-t-solid gl-border-t-gray-100 gl-rounded-base gl-rounded-top-left-none! gl-rounded-top-right-none!" diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index c6e7117cf2e..4f7256d0b0e 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -28,6 +28,7 @@ import CommentTypeDropdown from './comment_type_dropdown.vue'; import DiscussionLockedWidget from './discussion_locked_widget.vue'; import NoteSignedOutWidget from './note_signed_out_widget.vue'; +const ATTACHMENT_REGEXP = /!?\[.*?\]\(\/uploads\/[0-9a-f]{32}\/.*?\)/; export default { name: 'CommentForm', i18n: COMMENT_FORM, @@ -176,6 +177,9 @@ export default { disableSubmitButton() { return this.note.length === 0 || this.isSubmitting; }, + containsLink() { + return ATTACHMENT_REGEXP.test(this.note); + }, }, mounted() { // jQuery is needed here because it is a custom event being dispatched with jQuery. @@ -356,6 +360,7 @@ export default { :noteable-data="getNoteableData" :is-internal-note="noteIsInternal" :noteable-type="noteableType" + :contains-link="containsLink" > <markdown-field ref="markdownField" diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index c15c11ed9db..abed95a9706 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -4,12 +4,14 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import Api from '~/api'; import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status'; import { createAlert } from '~/flash'; +import { TYPE_ISSUE } from '~/issues/constants'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; import eventHub from '~/sidebar/event_hub'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { splitCamelCase } from '~/lib/utils/text_utility'; +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import ReplyButton from './note_actions/reply_button.vue'; import TimelineEventButton from './note_actions/timeline_event_button.vue'; @@ -30,6 +32,7 @@ export default { GlDropdownItem, UserAccessRoleBadge, EmojiPicker: () => import('~/emoji/components/picker.vue'), + AbuseCategorySelector, }, directives: { GlTooltip: GlTooltipDirective, @@ -58,11 +61,6 @@ export default { required: false, default: '', }, - reportAbusePath: { - type: String, - required: false, - default: null, - }, isAuthor: { type: Boolean, required: false, @@ -135,11 +133,16 @@ export default { default: '', }, }, + data() { + return { + isReportAbuseDrawerOpen: false, + }; + }, computed: { ...mapState(['isPromoteCommentToTimelineEventInProgress']), ...mapGetters(['getUserDataByProp', 'getNoteableData', 'canUserAddIncidentTimelineEvents']), shouldShowActionsDropdown() { - return this.currentUserId && (this.canEdit || this.canReportAsAbuse); + return this.currentUserId; }, showDeleteAction() { return this.canDelete && !this.canReportAsAbuse && !this.noteUrl; @@ -171,7 +174,7 @@ export default { return this.getNoteableData.assignees || []; }, isIssue() { - return this.targetType === 'issue'; + return this.targetType === TYPE_ISSUE; }, canAssign() { return this.getNoteableData.current_user?.can_set_issue_metadata && this.isIssue; @@ -233,7 +236,7 @@ export default { assignees.push({ id: this.author.id }); } - if (this.targetType === 'issue') { + if (this.targetType === TYPE_ISSUE) { Api.updateIssue(project_id, iid, { assignee_ids: assignees.map((assignee) => assignee.id), }) @@ -252,6 +255,9 @@ export default { awardName, }); }, + toggleReportAbuseDrawer(isOpen) { + this.isReportAbuseDrawerOpen = isOpen; + }, }, }; </script> @@ -261,7 +267,7 @@ export default { <user-access-role-badge v-if="isAuthor" v-gl-tooltip - class="gl-mr-3 d-none d-md-inline-block" + class="gl-mr-3 gl-display-none gl-sm-display-block" :title="displayAuthorBadgeText" > {{ __('Author') }} @@ -269,7 +275,7 @@ export default { <user-access-role-badge v-if="accessLevel" v-gl-tooltip - class="gl-mr-3" + class="gl-mr-3 gl-display-none gl-sm-display-block" :title="displayMemberBadgeText" > {{ accessLevel }} @@ -277,7 +283,7 @@ export default { <user-access-role-badge v-else-if="isContributor" v-gl-tooltip - class="gl-mr-3" + class="gl-mr-3 gl-display-none gl-sm-display-block" :title="displayContributorBadgeText" > {{ __('Contributor') }} @@ -334,7 +340,7 @@ export default { :aria-label="$options.i18n.editCommentLabel" icon="pencil" category="tertiary" - class="note-action-button js-note-edit" + class="note-action-button js-note-edit gl-display-none gl-sm-display-block" data-qa-selector="note_edit_button" @click="onEdit" /> @@ -362,7 +368,18 @@ export default { /> <!-- eslint-enable @gitlab/vue-no-data-toggle --> <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> - <gl-dropdown-item v-if="canReportAsAbuse" :href="reportAbusePath"> + <gl-dropdown-item + v-if="canEdit" + class="js-note-edit gl-sm-display-none!" + @click.prevent="onEdit" + > + {{ __('Edit comment') }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="canReportAsAbuse" + data-testid="report-abuse-button" + @click="toggleReportAbuseDrawer(true)" + > {{ $options.i18n.reportAbuse }} </gl-dropdown-item> <gl-dropdown-item @@ -380,5 +397,14 @@ export default { </gl-dropdown-item> </ul> </div> + <!-- IMPORTANT: show this component lazily because it causes layout thrashing --> + <!-- https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396 --> + <abuse-category-selector + v-if="canReportAsAbuse && isReportAbuseDrawerOpen" + :reported-user-id="authorId" + :reported-from-url="noteUrl" + :show-drawer="isReportAbuseDrawerOpen" + @close-drawer="toggleReportAbuseDrawer(false)" + /> </div> </template> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 79b6139d4b1..c83b3d870d7 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -96,6 +96,8 @@ export default { 'text-underline': this.isUsernameLinkHovered, 'author-name-link': true, 'js-user-link': true, + 'gl-overflow-hidden': true, + 'gl-overflow-wrap-break': true, }; }, authorName() { diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 826e7e5a3d0..93575ad57ff 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -43,6 +43,11 @@ export default { SafeHtml, }, mixins: [noteable, resolvable], + inject: { + reportAbusePath: { + default: '', + }, + }, props: { note: { type: Object, @@ -129,7 +134,7 @@ export default { }; }, canReportAsAbuse() { - return Boolean(this.note.report_abuse_path) && this.author.id !== this.getUserData.id; + return Boolean(this.reportAbusePath) && this.author.id !== this.getUserData.id; }, noteAnchorId() { return `note_${this.note.id}`; @@ -488,7 +493,6 @@ export default { :can-delete="note.current_user.can_edit" :can-report-as-abuse="canReportAsAbuse" :can-resolve="canResolve" - :report-abuse-path="note.report_abuse_path" :resolvable="note.resolvable || note.isDraft" :is-resolved="note.resolved || note.resolve_discussion" :is-resolving="isResolving" diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue index 9fc11ff65d5..2a0a3d5414f 100644 --- a/app/assets/javascripts/notes/components/sidebar_subscription.vue +++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue @@ -1,6 +1,6 @@ <script> import { mapActions } from 'vuex'; -import { IssuableType } from '~/issues/constants'; +import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants'; import { fetchPolicies } from '~/lib/graphql'; import { confidentialityQueries } from '~/sidebar/constants'; import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client'; @@ -28,7 +28,7 @@ export default { }, }, created() { - if (this.issuableType !== IssuableType.Issue && this.issuableType !== IssuableType.Epic) { + if (this.issuableType !== TYPE_ISSUE && this.issuableType !== TYPE_EPIC) { return; } diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index 734e08dd586..4437d461308 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -79,7 +79,7 @@ export default { :link-href="author.path" :img-alt="author.name" img-css-classes="gl-mr-0!" - :img-src="author.avatar_url" + :img-src="author.avatar_url || author.avatarUrl" :img-size="24" :tooltip-text="author.name" tooltip-placement="bottom" @@ -102,7 +102,10 @@ export default { </gl-link> </template> </gl-sprintf> - <time-ago-tooltip :time="lastReply.created_at" tooltip-placement="bottom" /> + <time-ago-tooltip + :time="lastReply.created_at || lastReply.createdAt" + tooltip-placement="bottom" + /> </template> <gl-button v-else diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js index 9b5fd69f816..a758a55014a 100644 --- a/app/assets/javascripts/notes/i18n.js +++ b/app/assets/javascripts/notes/i18n.js @@ -45,4 +45,7 @@ export const COMMENT_FORM = { commentHelp: __('Add a general comment to this %{noteableDisplayName}.'), internalCommentHelp: __('Add a confidential internal note to this %{noteableDisplayName}.'), }, + attachmentMsg: s__( + 'Notes|Attachments are sent by email. Attachments over 10 MB are sent as links to your GitLab instance, and only accessible to project members.', + ), }; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 95263e666b2..2e09c9f2288 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -52,6 +52,7 @@ export default () => { store, provide: { showTimelineViewToggle, + reportAbusePath: notesDataset.reportAbusePath, }, data() { return { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 5cad091ce2c..f6b9be6ee9b 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -4,6 +4,7 @@ import Vue from 'vue'; import Api from '~/api'; import { createAlert, VARIANT_INFO } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; @@ -20,7 +21,7 @@ import TaskList from '~/task_list'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; import SidebarStore from '~/sidebar/stores/sidebar_store'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_NOTE } from '~/graphql_shared/constants'; +import { TYPENAME_NOTE } from '~/graphql_shared/constants'; import notesEventHub from '../event_hub'; import promoteTimelineEvent from '../graphql/promote_timeline_event.mutation.graphql'; @@ -37,7 +38,8 @@ export const updateLockedAttribute = ({ commit, getters }, { locked, fullPath }) return utils.gqClient .mutate({ - mutation: targetType === 'issue' ? updateIssueLockMutation : updateMergeRequestLockMutation, + mutation: + targetType === TYPE_ISSUE ? updateIssueLockMutation : updateMergeRequestLockMutation, variables: { input: { projectPath: fullPath, @@ -48,7 +50,7 @@ export const updateLockedAttribute = ({ commit, getters }, { locked, fullPath }) }) .then(({ data }) => { const discussionLocked = - targetType === 'issue' + targetType === TYPE_ISSUE ? data.issueSetLocked.issue.discussionLocked : data.mergeRequestSetLocked.mergeRequest.discussionLocked; @@ -276,7 +278,7 @@ export const promoteCommentToTimelineEvent = ( mutation: promoteTimelineEvent, variables: { input: { - noteId: convertToGraphQLId(TYPE_NOTE, noteId), + noteId: convertToGraphQLId(TYPENAME_NOTE, noteId), }, }, }) diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue index 787f21d9419..d982df4f984 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue @@ -1,15 +1,30 @@ <script> +import { n__ } from '~/locale'; import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue'; +import { + CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, +} from '~/packages_and_registries/package_registry/constants'; +import Tracking from '~/tracking'; export default { components: { + DeleteModal, VersionRow, PackagesListLoader, RegistryList, }, + mixins: [Tracking.mixin()], props: { + canDestroy: { + type: Boolean, + required: false, + default: false, + }, versions: { type: Array, required: true, @@ -25,11 +40,35 @@ export default { default: false, }, }, + data() { + return { + itemsToBeDeleted: [], + }; + }, computed: { + listTitle() { + return n__('%d version', '%d versions', this.versions.length); + }, isListEmpty() { return this.versions.length === 0; }, }, + methods: { + deleteItemsCanceled() { + this.track(CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION); + this.itemsToBeDeleted = []; + }, + deleteItemsConfirmation() { + this.$emit('delete', this.itemsToBeDeleted); + this.track(DELETE_PACKAGE_VERSIONS_TRACKING_ACTION); + this.itemsToBeDeleted = []; + }, + setItemsToBeDeleted(items) { + this.itemsToBeDeleted = items; + this.track(REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION); + this.$refs.deletePackagesModal.show(); + }, + }, }; </script> <template> @@ -40,17 +79,34 @@ export default { <slot v-else-if="isListEmpty" name="empty-state"></slot> <div v-else> <registry-list - :hidden-delete="true" + :hidden-delete="!canDestroy" :is-loading="isLoading" :items="versions" :pagination="pageInfo" + :title="listTitle" + @delete="setItemsToBeDeleted" @prev-page="$emit('prev-page')" @next-page="$emit('next-page')" > - <template #default="{ item }"> - <version-row :package-entity="item" /> + <template #default="{ first, item, isSelected, selectItem }"> + <!-- `first` prop is used to decide whether to show the top border + for the first element. We want to show the top border only when + user has permission to bulk delete versions. --> + <version-row + :first="canDestroy && first" + :package-entity="item" + :selected="isSelected(item)" + @select="selectItem(item)" + /> </template> </registry-list> + + <delete-modal + ref="deletePackagesModal" + :items-to-be-deleted="itemsToBeDeleted" + @confirm="deleteItemsConfirmation" + @cancel="deleteItemsCanceled" + /> </div> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue index 57ff3cd2a83..9f8f6328970 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue @@ -1,15 +1,29 @@ <script> -import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui'; +import { + GlFormCheckbox, + GlIcon, + GlLink, + GlSprintf, + GlTooltipDirective, + GlTruncate, +} from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { PACKAGE_DEFAULT_STATUS } from '../../constants'; +import { + ERRORED_PACKAGE_TEXT, + ERROR_PUBLISHING, + PACKAGE_ERROR_STATUS, + WARNING_TEXT, +} from '../../constants'; export default { - name: 'PackageListRow', + name: 'PackageVersionRow', components: { + GlFormCheckbox, + GlIcon, GlLink, GlSprintf, GlTruncate, @@ -18,30 +32,55 @@ export default { ListItem, TimeAgoTooltip, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { packageEntity: { type: Object, required: true, }, + selected: { + type: Boolean, + default: false, + required: false, + }, }, computed: { + containsWebPathLink() { + return Boolean(this.packageEntity?._links?.webPath); + }, packageLink() { return `${getIdFromGraphQLId(this.packageEntity.id)}`; }, - disabledRow() { - return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS; + errorStatusRow() { + return this.packageEntity?.status === PACKAGE_ERROR_STATUS; }, }, + i18n: { + erroredPackageText: ERRORED_PACKAGE_TEXT, + errorPublishing: ERROR_PUBLISHING, + warningText: WARNING_TEXT, + }, }; </script> <template> - <list-item :disabled="disabledRow"> + <list-item :selected="selected" v-bind="$attrs"> + <template #left-action> + <gl-form-checkbox + v-if="packageEntity.canDestroy" + class="gl-m-0" + :checked="selected" + @change="$emit('select')" + /> + </template> <template #left-primary> <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"> - <gl-link :href="packageLink" class="gl-text-body gl-min-w-0" :disabled="disabledRow"> + <gl-link v-if="containsWebPathLink" class="gl-text-body gl-min-w-0" :href="packageLink"> <gl-truncate :text="packageEntity.name" /> </gl-link> + <gl-truncate v-else :text="packageEntity.name" /> <package-tags v-if="packageEntity.tags.nodes && packageEntity.tags.nodes.length" @@ -53,7 +92,20 @@ export default { </div> </template> <template #left-secondary> - {{ packageEntity.version }} + <div v-if="errorStatusRow" class="gl-text-red-500"> + <gl-icon + v-gl-tooltip="{ title: $options.i18n.erroredPackageText }" + name="warning" + :aria-label="$options.i18n.warningText" + /> + <span>{{ $options.i18n.errorPublishing }}</span> + </div> + <gl-truncate + v-else + class="gl-max-w-15 gl-md-max-w-26" + :text="packageEntity.version" + :with-tooltip="true" + /> </template> <template #right-primary> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue deleted file mode 100644 index e1cf4883029..00000000000 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue +++ /dev/null @@ -1,62 +0,0 @@ -<script> -import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql'; -import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash'; -import { s__ } from '~/locale'; - -import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/package_registry/constants'; - -export default { - props: { - refetchQueries: { - type: Array, - required: false, - default: null, - }, - showSuccessAlert: { - type: Boolean, - required: false, - default: false, - }, - }, - i18n: { - errorMessage: s__('PackageRegistry|Something went wrong while deleting the package.'), - successMessage: DELETE_PACKAGE_SUCCESS_MESSAGE, - }, - methods: { - async deletePackage(packageEntity) { - try { - this.$emit('start'); - const { data } = await this.$apollo.mutate({ - mutation: destroyPackageMutation, - variables: { - id: packageEntity.id, - }, - awaitRefetchQueries: Boolean(this.refetchQueries), - refetchQueries: this.refetchQueries, - }); - - if (data?.destroyPackage?.errors[0]) { - throw data.destroyPackage.errors[0]; - } - if (this.showSuccessAlert) { - createAlert({ - message: this.$options.i18n.successMessage, - variant: VARIANT_SUCCESS, - }); - } - } catch (error) { - createAlert({ - message: this.$options.i18n.errorMessage, - variant: VARIANT_WARNING, - captureError: true, - error, - }); - } - this.$emit('end'); - }, - }, - render() { - return this.$scopedSlots.default({ deletePackage: this.deletePackage }); - }, -}; -</script> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue new file mode 100644 index 00000000000..0914c013108 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue @@ -0,0 +1,76 @@ +<script> +import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql'; +import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash'; + +import { + DELETE_PACKAGE_ERROR_MESSAGE, + DELETE_PACKAGE_SUCCESS_MESSAGE, + DELETE_PACKAGES_ERROR_MESSAGE, + DELETE_PACKAGES_SUCCESS_MESSAGE, +} from '~/packages_and_registries/package_registry/constants'; + +export default { + name: 'DeletePackages', + props: { + refetchQueries: { + type: Array, + required: false, + default: null, + }, + showSuccessAlert: { + type: Boolean, + required: false, + default: false, + }, + }, + i18n: { + errorMessage: DELETE_PACKAGE_ERROR_MESSAGE, + errorMessageMultiple: DELETE_PACKAGES_ERROR_MESSAGE, + successMessage: DELETE_PACKAGE_SUCCESS_MESSAGE, + successMessageMultiple: DELETE_PACKAGES_SUCCESS_MESSAGE, + }, + methods: { + async deletePackages(packageEntities) { + const isSinglePackage = packageEntities.length === 1; + try { + this.$emit('start'); + const ids = packageEntities.map((packageEntity) => packageEntity.id); + const { data } = await this.$apollo.mutate({ + mutation: destroyPackagesMutation, + variables: { + ids, + }, + awaitRefetchQueries: Boolean(this.refetchQueries), + refetchQueries: this.refetchQueries, + }); + + if (data?.destroyPackages?.errors[0]) { + throw data.destroyPackages.errors[0]; + } + + if (this.showSuccessAlert) { + createAlert({ + message: isSinglePackage + ? this.$options.i18n.successMessage + : this.$options.i18n.successMessageMultiple, + variant: VARIANT_SUCCESS, + }); + } + } catch (error) { + createAlert({ + message: isSinglePackage + ? this.$options.i18n.errorMessage + : this.$options.i18n.errorMessageMultiple, + variant: VARIANT_WARNING, + captureError: true, + error, + }); + } + this.$emit('end'); + }, + }, + render() { + return this.$scopedSlots.default({ deletePackages: this.deletePackages }); + }, +}; +</script> 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 7ad1ebac11e..16f21bfe61d 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 @@ -11,8 +11,11 @@ import { import { s__, __ } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { + ERRORED_PACKAGE_TEXT, + ERROR_PUBLISHING, PACKAGE_ERROR_STATUS, PACKAGE_DEFAULT_STATUS, + WARNING_TEXT, } from '~/packages_and_registries/package_registry/constants'; import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils'; import PackagePath from '~/packages_and_registries/shared/components/package_path.vue'; @@ -78,9 +81,6 @@ export default { nonDefaultRow() { return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS; }, - routerLinkEvent() { - return this.nonDefaultRow ? '' : 'click'; - }, errorPackageStyle() { return { 'gl-text-red-500': this.errorStatusRow, @@ -89,18 +89,18 @@ export default { }, }, i18n: { - erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'), + erroredPackageText: ERRORED_PACKAGE_TEXT, createdAt: __('Created %{timestamp}'), deletePackage: s__('PackageRegistry|Delete package'), - errorPublishing: s__('PackageRegistry|Error publishing'), - warning: __('Warning'), + errorPublishing: ERROR_PUBLISHING, + warning: WARNING_TEXT, moreActions: __('More actions'), }, }; </script> <template> - <list-item data-testid="package-row" v-bind="$attrs"> + <list-item data-testid="package-row" :selected="selected" v-bind="$attrs"> <template #left-action> <gl-form-checkbox v-if="packageEntity.canDestroy" @@ -117,7 +117,6 @@ export default { class="gl-text-body gl-min-w-0" data-testid="details-link" data-qa-selector="package_link" - :event="routerLinkEvent" :to="{ name: 'details', params: { id: packageId } }" > <gl-truncate :text="packageEntity.name" /> @@ -134,8 +133,16 @@ export default { </div> </template> <template #left-secondary> - <div v-if="!errorStatusRow" class="gl-display-flex" data-testid="left-secondary-infos"> - <span>{{ packageEntity.version }}</span> + <div + v-if="!errorStatusRow" + class="gl-display-flex gl-align-items-center" + data-testid="left-secondary-infos" + > + <gl-truncate + class="gl-max-w-15 gl-md-max-w-26" + :text="packageEntity.version" + :with-tooltip="true" + /> <div v-if="pipelineUser" class="gl-display-none gl-sm-display-flex gl-ml-2"> <gl-sprintf :message="s__('PackageRegistry|published by %{author}')"> 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 40bf7b7e143..486ab4fdc99 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 @@ -112,7 +112,7 @@ export default { this.itemsToBeDeleted = []; }, deleteItemConfirmation() { - this.$emit('package:delete', this.itemToBeDeleted); + this.$emit('delete', [this.itemToBeDeleted]); this.track(DELETE_PACKAGE_TRACKING_ACTION); this.itemToBeDeleted = null; }, diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js index 539b12bd6db..d979ae5c08c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -115,6 +115,10 @@ export const DELETE_PACKAGES_TRACKING_ACTION = 'delete_packages'; export const REQUEST_DELETE_PACKAGES_TRACKING_ACTION = 'request_delete_packages'; export const CANCEL_DELETE_PACKAGES_TRACKING_ACTION = 'cancel_delete_packages'; +export const DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'delete_package_versions'; +export const REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'request_delete_package_versions'; +export const CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'cancel_delete_package_versions'; + export const DELETE_PACKAGES_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting packages.', ); @@ -124,6 +128,16 @@ export const DELETE_PACKAGES_MODAL_TITLE = s__('PackageRegistry|Delete packages' export const DELETE_PACKAGE_MODAL_PRIMARY_ACTION = s__('PackageRegistry|Permanently delete'); export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully'); +export const DELETE_PACKAGE_ERROR_MESSAGE = s__( + 'PackageRegistry|Something went wrong while deleting the package.', +); + +export const ERRORED_PACKAGE_TEXT = s__( + 'PackageRegistry|Invalid Package: failed metadata extraction', +); +export const ERROR_PUBLISHING = s__('PackageRegistry|Error publishing'); +export const WARNING_TEXT = __('Warning'); + export const PACKAGE_REGISTRY_TITLE = __('Package Registry'); export const PACKAGE_ERROR_STATUS = 'ERROR'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql deleted file mode 100644 index 884980f24a9..00000000000 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation destroyPackage($id: PackagesPackageID!) { - destroyPackage(input: { id: $id }) { - errors - } -} 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 9153906a38c..109d535469b 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 @@ -66,9 +66,13 @@ query getPackageDetails( nodes { id name + canDestroy createdAt version status + _links { + webPath + } tags(first: 1) { nodes { id diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js index 336eb0ca079..15ed98122a0 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/index.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js @@ -36,7 +36,7 @@ export default () => { const attachMainComponent = () => new Vue({ el, - name: 'PackageRegistery', + name: 'PackageRegistry', router, apolloProvider, provide: { diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue index 03352f01aca..4591c2eca87 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue @@ -11,6 +11,7 @@ import { GlSprintf, } from '@gitlab/ui'; import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash'; +import { TYPENAME_PACKAGES_PACKAGE } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { objectToQuery } from '~/lib/utils/url_utility'; @@ -23,7 +24,7 @@ import PackageFiles from '~/packages_and_registries/package_registry/components/ import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue'; import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue'; -import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; +import DeletePackages from '~/packages_and_registries/package_registry/components/functional/delete_packages.vue'; import { PACKAGE_TYPE_NUGET, PACKAGE_TYPE_COMPOSER, @@ -71,7 +72,7 @@ export default { AdditionalMetadata, InstallationCommands, PackageFiles, - DeletePackage, + DeletePackages, PackageVersionsList, }, directives: { @@ -94,6 +95,7 @@ export default { deletePackageModalContent: DELETE_MODAL_CONTENT, filesToDelete: [], mutationLoading: false, + versionsMutationLoading: false, packageEntity: {}, }; }, @@ -132,7 +134,7 @@ export default { }, queryVariables() { return { - id: convertToGraphQLId('Packages::Package', this.packageId), + id: convertToGraphQLId(TYPENAME_PACKAGES_PACKAGE, this.packageId), first: GRAPHQL_PAGE_SIZE, }; }, @@ -145,6 +147,9 @@ export default { isLoading() { return this.$apollo.queries.packageEntity.loading; }, + isVersionsLoading() { + return this.isLoading || this.versionsMutationLoading; + }, packageFilesLoading() { return this.isLoading || this.mutationLoading; }, @@ -156,9 +161,6 @@ export default { category: packageTypeToTrackCategory(this.packageType), }; }, - hasVersions() { - return this.packageEntity.versions?.nodes?.length > 0; - }, versionPageInfo() { return this.packageEntity?.versions?.pageInfo ?? {}; }, @@ -180,6 +182,14 @@ export default { PACKAGE_TYPE_PYPI, ].includes(this.packageType); }, + refetchQueriesData() { + return [ + { + query: getPackageDetails, + variables: this.queryVariables, + }, + ]; + }, }, methods: { formatSize(size) { @@ -205,12 +215,7 @@ export default { ids, }, awaitRefetchQueries: true, - refetchQueries: [ - { - query: getPackageDetails, - variables: this.queryVariables, - }, - ], + refetchQueries: this.refetchQueriesData, }); if (data?.destroyPackageFiles?.errors[0]) { throw data.destroyPackageFiles.errors[0]; @@ -402,27 +407,38 @@ export default { }}</gl-badge> </template> - <package-versions-list - :is-loading="isLoading" - :page-info="versionPageInfo" - :versions="packageEntity.versions.nodes" - @prev-page="fetchPreviousVersionsPage" - @next-page="fetchNextVersionsPage" + <delete-packages + :refetch-queries="refetchQueriesData" + show-success-alert + @start="versionsMutationLoading = true" + @end="versionsMutationLoading = false" > - <template #empty-state> - <p class="gl-mt-3" data-testid="no-versions-message"> - {{ s__('PackageRegistry|There are no other versions of this package.') }} - </p> + <template #default="{ deletePackages }"> + <package-versions-list + :can-destroy="packageEntity.canDestroy" + :is-loading="isVersionsLoading" + :page-info="versionPageInfo" + :versions="packageEntity.versions.nodes" + @delete="deletePackages" + @prev-page="fetchPreviousVersionsPage" + @next-page="fetchNextVersionsPage" + > + <template #empty-state> + <p class="gl-mt-3" data-testid="no-versions-message"> + {{ s__('PackageRegistry|There are no other versions of this package.') }} + </p> + </template> + </package-versions-list> </template> - </package-versions-list> + </delete-packages> </gl-tab> </gl-tabs> - <delete-package + <delete-packages @start="track($options.trackingActions.DELETE_PACKAGE_TRACKING_ACTION)" @end="navigateToListWithSuccessModal" > - <template #default="{ deletePackage }"> + <template #default="{ deletePackages }"> <gl-modal ref="deleteModal" size="sm" @@ -430,7 +446,7 @@ export default { data-testid="delete-modal" :action-primary="$options.modal.packageDeletePrimaryAction" :action-cancel="$options.modal.cancelAction" - @primary="deletePackage(packageEntity)" + @primary="deletePackages([packageEntity])" @hidden="resetDeleteModalContent" @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)" > @@ -446,7 +462,7 @@ export default { </gl-sprintf> </gl-modal> </template> - </delete-package> + </delete-packages> <gl-modal ref="deleteFileModal" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue index 396429d60d8..31c76c95e45 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue @@ -1,6 +1,6 @@ <script> -import { GlAlert, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; -import { createAlert, VARIANT_INFO, VARIANT_SUCCESS, VARIANT_DANGER } from '~/flash'; +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { createAlert, VARIANT_INFO } from '~/flash'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants'; @@ -9,33 +9,28 @@ import { GROUP_RESOURCE_TYPE, GRAPHQL_PAGE_SIZE, DELETE_PACKAGE_SUCCESS_MESSAGE, - DELETE_PACKAGES_ERROR_MESSAGE, - DELETE_PACKAGES_SUCCESS_MESSAGE, EMPTY_LIST_HELP_URL, PACKAGE_HELP_URL, } from '~/packages_and_registries/package_registry/constants'; import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; -import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql'; -import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; +import DeletePackages from '~/packages_and_registries/package_registry/components/functional/delete_packages.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue'; import PackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; export default { components: { - GlAlert, GlEmptyState, GlLink, GlSprintf, PackageList, PackageTitle, PackageSearch, - DeletePackage, + DeletePackages, }, inject: ['emptyListIllustration', 'isGroupPage', 'fullPath'], data() { return { - alertVariables: null, packages: {}, sort: '', filters: {}, @@ -114,39 +109,6 @@ export default { historyReplaceState(cleanUrl); } }, - async deletePackages(packageEntities) { - this.mutationLoading = true; - try { - const { data } = await this.$apollo.mutate({ - mutation: destroyPackagesMutation, - variables: { - ids: packageEntities.map((i) => i.id), - }, - awaitRefetchQueries: true, - refetchQueries: [ - { - query: getPackagesQuery, - variables: { ...this.queryVariables, first: GRAPHQL_PAGE_SIZE }, - }, - ], - }); - - if (data?.destroyPackages?.errors[0]) { - throw new Error(data.destroyPackages.errors[0]); - } - this.showAlert({ - variant: VARIANT_SUCCESS, - message: DELETE_PACKAGES_SUCCESS_MESSAGE, - }); - } catch { - this.showAlert({ - variant: VARIANT_DANGER, - message: DELETE_PACKAGES_ERROR_MESSAGE, - }); - } finally { - this.mutationLoading = false; - } - }, handleSearchUpdate({ sort, filters }) { this.sort = sort; this.filters = { ...filters }; @@ -180,9 +142,6 @@ export default { updateQuery: this.updateQuery, }); }, - showAlert(obj) { - this.alertVariables = { ...obj }; - }, }, i18n: { widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'), @@ -201,32 +160,22 @@ export default { <template> <div> - <gl-alert - v-if="alertVariables" - :variant="alertVariables.variant" - class="gl-mt-5" - dismissible - @dismiss="alertVariables = null" - > - {{ alertVariables.message }} - </gl-alert> <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount" /> <package-search class="gl-mb-5" @update="handleSearchUpdate" /> - <delete-package + <delete-packages :refetch-queries="refetchQueriesData" show-success-alert @start="mutationLoading = true" @end="mutationLoading = false" > - <template #default="{ deletePackage }"> + <template #default="{ deletePackages }"> <package-list :list="packages.nodes" :is-loading="isLoading" :page-info="pageInfo" @prev-page="fetchPreviousPage" @next-page="fetchNextPage" - @package:delete="deletePackage" @delete="deletePackages" > <template #empty-state> @@ -245,6 +194,6 @@ export default { </template> </package-list> </template> - </delete-package> + </delete-packages> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue index f1f0b970b15..f95ec4336dc 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue @@ -1,5 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; +import { sprintf } from '~/locale'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, @@ -7,10 +8,14 @@ import { KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME, KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL, SET_CLEANUP_POLICY_BUTTON, + READY_FOR_CLEANUP_MESSAGE, + TIME_TO_NEXT_CLEANUP_MESSAGE, } from '~/packages_and_registries/settings/project/constants'; +import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql'; import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql'; import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils'; import Tracking from '~/tracking'; +import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility'; import ExpirationDropdown from './expiration_dropdown.vue'; export default { @@ -36,6 +41,8 @@ export default { KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL, KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION, SET_CLEANUP_POLICY_BUTTON, + TIME_TO_NEXT_CLEANUP_MESSAGE, + READY_FOR_CLEANUP_MESSAGE, }, data() { return { @@ -69,6 +76,15 @@ export default { keepNDuplicatedPackageFiles: this.prefilledForm.keepNDuplicatedPackageFiles, }; }, + nextCleanupMessage() { + const { nextRunAt } = this.value; + const difference = calculateRemainingMilliseconds(nextRunAt); + return difference + ? sprintf(TIME_TO_NEXT_CLEANUP_MESSAGE, { + nextRunAt: approximateDuration(difference / 1000), + }) + : READY_FOR_CLEANUP_MESSAGE; + }, }, methods: { findDefaultOption(option) { @@ -83,6 +99,15 @@ export default { variables: { input: this.mutationVariables, }, + awaitRefetchQueries: true, + refetchQueries: [ + { + query: packagesCleanupPolicyQuery, + variables: { + projectPath: this.projectPath, + }, + }, + ], }) .then(({ data }) => { const [errorMessage] = data?.updatePackagesCleanupPolicy?.errors ?? []; @@ -119,6 +144,9 @@ export default { data-testid="keep-n-duplicated-package-files-dropdown" @input="onModelChange($event, 'keepNDuplicatedPackageFiles')" /> + <p v-if="value.nextRunAt" data-testid="next-run-at"> + {{ nextCleanupMessage }} + </p> <div class="gl-mt-7 gl-display-flex gl-align-items-center"> <gl-button data-testid="save-button" diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js index a9b47cbd343..731fb3e4c45 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -74,6 +74,12 @@ export const KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL = s__( export const KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION = s__( 'PackageRegistry|Examples of assets include .pom & .jar files', ); +export const TIME_TO_NEXT_CLEANUP_MESSAGE = s__( + 'PackageRegistry|Packages and assets will not be deleted until cleanup runs in %{nextRunAt}.', +); +export const READY_FOR_CLEANUP_MESSAGE = s__( + 'PackageRegistry|Packages and assets cleanup is ready to be executed when the next cleanup job runs.', +); export const KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME = 'keepNDuplicatedPackageFiles'; diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue index d07d0a7673f..7485f8282ee 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlFormCheckbox, GlKeysetPagination } from '@gitlab/ui'; -import { filter } from 'lodash'; import { __ } from '~/locale'; export default { @@ -52,24 +51,31 @@ export default { return this.pagination.hasPreviousPage || this.pagination.hasNextPage; }, disableDeleteButton() { - return this.isLoading || filter(this.selectedReferences).length === 0; + return this.isLoading || this.selectedItems.length === 0; }, selectedItems() { return this.items.filter(this.isSelected); }, - selectAll: { - get() { - return this.items.every(this.isSelected); - }, - set(value) { - this.items.forEach((item) => { - const id = item[this.idProperty]; - this.$set(this.selectedReferences, id, value); - }); - }, + disabled() { + return this.items.length === 0; + }, + checked() { + return this.items.every(this.isSelected); + }, + indeterminate() { + return !this.checked && this.items.some(this.isSelected); + }, + label() { + return this.checked ? __('Unselect all') : __('Select all'); }, }, methods: { + onChange(event) { + this.items.forEach((item) => { + const id = item[this.idProperty]; + this.$set(this.selectedReferences, id, event); + }); + }, selectItem(item) { const id = item[this.idProperty]; this.$set(this.selectedReferences, id, !this.selectedReferences[id]); @@ -80,7 +86,7 @@ export default { }, }, i18n: { - deleteSelected: __('Delete Selected'), + deleteSelected: __('Delete selected'), }, }; </script> @@ -91,9 +97,18 @@ export default { v-if="!hiddenDelete" class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-align-items-center" > - <gl-form-checkbox v-model="selectAll" class="gl-ml-2 gl-pt-2"> - <span class="gl-font-weight-bold">{{ title }}</span> - </gl-form-checkbox> + <div class="gl-display-flex gl-align-items-center"> + <gl-form-checkbox + class="gl-ml-2 gl-pt-2" + :aria-label="label" + :checked="checked" + :disabled="disabled" + :indeterminate="indeterminate" + @change="onChange" + /> + + <p class="gl-font-weight-bold gl-mb-0">{{ title }}</p> + </div> <gl-button :disabled="disableDeleteButton" diff --git a/app/assets/javascripts/pages/abuse_reports/index.js b/app/assets/javascripts/pages/abuse_reports/index.js new file mode 100644 index 00000000000..feceeb0b10a --- /dev/null +++ b/app/assets/javascripts/pages/abuse_reports/index.js @@ -0,0 +1,3 @@ +import { initLinkToSpam } from '~/abuse_reports'; + +initLinkToSpam(); diff --git a/app/assets/javascripts/pages/admin/application_settings/account_and_limits.js b/app/assets/javascripts/pages/admin/application_settings/account_and_limits.js index 455c637a6b3..8b8147425bc 100644 --- a/app/assets/javascripts/pages/admin/application_settings/account_and_limits.js +++ b/app/assets/javascripts/pages/admin/application_settings/account_and_limits.js @@ -20,10 +20,65 @@ function setUserInternalRegexPlaceholder(checkbox) { } } -export default function initUserInternalRegexPlaceholder() { +function initUserInternalRegexPlaceholder() { const checkbox = document.getElementById('application_setting_user_default_external'); setUserInternalRegexPlaceholder(checkbox); checkbox.addEventListener('change', () => { setUserInternalRegexPlaceholder(checkbox); }); } + +/** + * Sets up logic inside "Dormant users" subsection: + * - checkbox enables/disables additional input + * - shows/hides an inline error on input validation + */ +function initDeactivateDormantUsersPeriodInputSection() { + const DISPLAY_NONE_CLASS = 'gl-display-none'; + + /** @type {HTMLInputElement} */ + const checkbox = document.getElementById('application_setting_deactivate_dormant_users'); + /** @type {HTMLInputElement} */ + const input = document.getElementById('application_setting_deactivate_dormant_users_period'); + /** @type {HTMLDivElement} */ + const errorLabel = document.getElementById( + 'application_setting_deactivate_dormant_users_period_error', + ); + + if (!checkbox || !input || !errorLabel) return; + + const hideInputErrorLabel = () => { + if (input.checkValidity()) { + errorLabel.classList.add(DISPLAY_NONE_CLASS); + } + }; + + const handleInputInvalidState = (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + errorLabel.classList.remove(DISPLAY_NONE_CLASS); + return false; + }; + + const updateInputDisabledState = () => { + input.disabled = !checkbox.checked; + if (input.disabled) { + hideInputErrorLabel(); + } + }; + + // Show error when input is invalid + input.addEventListener('invalid', handleInputInvalidState); + // Hide error when input changes + input.addEventListener('input', hideInputErrorLabel); + input.addEventListener('change', hideInputErrorLabel); + + // Handle checkbox change and set initial state + checkbox.addEventListener('change', updateInputDisabledState); + updateInputDisabledState(); +} + +export default function initAccountAndLimitsSection() { + initUserInternalRegexPlaceholder(); + initDeactivateDormantUsersPeriodInputSection(); +} diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js index c48d99da990..8a810ca649c 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js @@ -1,9 +1,9 @@ -import initUserInternalRegexPlaceholder from '../account_and_limits'; +import initAccountAndLimitsSection from '../account_and_limits'; import initGitpod from '../gitpod'; import initSignupRestrictions from '../signup_restrictions'; (() => { - initUserInternalRegexPlaceholder(); + initAccountAndLimitsSection(); initGitpod(); initSignupRestrictions(); })(); diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js index f1e92cf195a..366be334e87 100644 --- a/app/assets/javascripts/pages/admin/application_settings/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/index.js @@ -1,5 +1,4 @@ import initVariableList from '~/ci/ci_variable_list'; -import projectSelect from '~/project_select'; import initSearchSettings from '~/search_settings'; import selfMonitor from '~/self_monitor'; import initSettingsPanels from '~/settings_panels'; @@ -8,5 +7,4 @@ initVariableList('js-instance-variables'); selfMonitor(); // Initialize expandable settings panels initSettingsPanels(); -projectSelect(); initSearchSettings(); diff --git a/app/assets/javascripts/pages/admin/hooks/index.js b/app/assets/javascripts/pages/admin/hooks/index.js new file mode 100644 index 00000000000..82e601426f1 --- /dev/null +++ b/app/assets/javascripts/pages/admin/hooks/index.js @@ -0,0 +1,3 @@ +import { initHookTestDropdowns } from '~/webhooks'; + +initHookTestDropdowns(); diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue new file mode 100644 index 00000000000..72cfc005782 --- /dev/null +++ b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue @@ -0,0 +1,37 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import CancelJobsModal from './cancel_jobs_modal.vue'; +import { CANCEL_JOBS_MODAL_ID, CANCEL_JOBS_BUTTON_TEXT, CANCEL_BUTTON_TOOLTIP } from './constants'; + +export default { + name: 'CancelJobs', + components: { + GlButton, + CancelJobsModal, + }, + directives: { + GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, + }, + props: { + url: { + type: String, + required: true, + }, + }, + modalId: CANCEL_JOBS_MODAL_ID, + buttonText: CANCEL_JOBS_BUTTON_TEXT, + buttonTooltip: CANCEL_BUTTON_TOOLTIP, +}; +</script> +<template> + <div> + <gl-button + v-gl-modal="$options.modalId" + v-gl-tooltip="$options.buttonTooltip" + variant="danger" + >{{ $options.buttonText }}</gl-button + > + <cancel-jobs-modal :modal-id="$options.modalId" :url="url" @confirm="$emit('confirm')" /> + </div> +</template> diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue index b608b3b9492..d5857294617 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue @@ -5,10 +5,9 @@ import axios from '~/lib/utils/axios_utils'; import { redirectTo } from '~/lib/utils/url_utility'; import { CANCEL_TEXT, - STOP_JOBS_MODAL_ID, - STOP_JOBS_FAILED_TEXT, - STOP_JOBS_MODAL_TITLE, - STOP_JOBS_WARNING, + CANCEL_JOBS_FAILED_TEXT, + CANCEL_JOBS_MODAL_TITLE, + CANCEL_JOBS_WARNING, PRIMARY_ACTION_TEXT, } from './constants'; @@ -21,6 +20,10 @@ export default { type: String, required: true, }, + modalId: { + type: String, + required: true, + }, }, methods: { onSubmit() { @@ -32,7 +35,7 @@ export default { }) .catch((error) => { createAlert({ - message: STOP_JOBS_FAILED_TEXT, + message: CANCEL_JOBS_FAILED_TEXT, }); throw error; }); @@ -45,20 +48,19 @@ export default { cancelAction: { text: CANCEL_TEXT, }, - STOP_JOBS_WARNING, - STOP_JOBS_MODAL_ID, - STOP_JOBS_MODAL_TITLE, + CANCEL_JOBS_WARNING, + CANCEL_JOBS_MODAL_TITLE, }; </script> <template> <gl-modal - :modal-id="$options.STOP_JOBS_MODAL_ID" + :modal-id="modalId" :action-primary="$options.primaryAction" :action-cancel="$options.cancelAction" + :title="$options.CANCEL_JOBS_MODAL_TITLE" @primary="onSubmit" > - <template #modal-title>{{ $options.STOP_JOBS_MODAL_TITLE }}</template> - {{ $options.STOP_JOBS_WARNING }} + {{ $options.CANCEL_JOBS_WARNING }} </gl-modal> </template> diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js b/app/assets/javascripts/pages/admin/jobs/index/components/constants.js index 9e2d464bc4d..cfde1fc0a2b 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js +++ b/app/assets/javascripts/pages/admin/jobs/index/components/constants.js @@ -1,11 +1,12 @@ import { s__, __ } from '~/locale'; -export const STOP_JOBS_MODAL_ID = 'stop-jobs-modal'; -export const STOP_JOBS_MODAL_TITLE = s__('AdminArea|Stop all jobs?'); -export const STOP_JOBS_BUTTON_TEXT = s__('AdminArea|Stop all jobs'); +export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal'; +export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?'); +export const CANCEL_JOBS_BUTTON_TEXT = s__('AdminArea|Cancel all jobs'); +export const CANCEL_BUTTON_TOOLTIP = s__('AdminArea|Cancel all running and pending jobs'); export const CANCEL_TEXT = __('Cancel'); -export const STOP_JOBS_FAILED_TEXT = s__('AdminArea|Stopping jobs failed'); -export const PRIMARY_ACTION_TEXT = s__('AdminArea|Stop jobs'); -export const STOP_JOBS_WARNING = s__( - 'AdminArea|You’re about to stop all jobs. This will halt all current jobs that are running.', +export const CANCEL_JOBS_FAILED_TEXT = s__('AdminArea|Canceling jobs failed'); +export const PRIMARY_ACTION_TEXT = s__('AdminArea|Yes, proceed'); +export const CANCEL_JOBS_WARNING = s__( + "AdminArea|You're about to cancel all running and pending jobs across this instance. Do you want to proceed?", ); diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue new file mode 100644 index 00000000000..c5a0509b625 --- /dev/null +++ b/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue @@ -0,0 +1,19 @@ +<script> +export default { + inject: { + jobStatuses: { + default: null, + }, + url: { + default: '', + }, + emptyStateSvgPath: { + default: '', + }, + }, +}; +</script> + +<template> + <div>{{ __('Jobs') }}</div> +</template> diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js index c82b186f671..9df52557212 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/index.js +++ b/app/assets/javascripts/pages/admin/jobs/index/index.js @@ -1,31 +1,33 @@ import Vue from 'vue'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import Translate from '~/vue_shared/translate'; -import { STOP_JOBS_MODAL_ID } from './components/constants'; -import StopJobsModal from './components/stop_jobs_modal.vue'; +import { CANCEL_JOBS_MODAL_ID } from './components/constants'; +import CancelJobsModal from './components/cancel_jobs_modal.vue'; +import AdminJobsTableApp from './components/table/admin_jobs_table_app.vue'; Vue.use(Translate); function initJobs() { const buttonId = 'js-stop-jobs-button'; - const stopJobsButton = document.getElementById(buttonId); - if (stopJobsButton) { + const cancelJobsButton = document.getElementById(buttonId); + if (cancelJobsButton) { // eslint-disable-next-line no-new new Vue({ - el: `#js-${STOP_JOBS_MODAL_ID}`, + el: `#js-${CANCEL_JOBS_MODAL_ID}`, components: { - StopJobsModal, + CancelJobsModal, }, mounted() { - stopJobsButton.classList.remove('disabled'); - stopJobsButton.addEventListener('click', () => { - this.$root.$emit(BV_SHOW_MODAL, STOP_JOBS_MODAL_ID, `#${buttonId}`); + cancelJobsButton.classList.remove('disabled'); + cancelJobsButton.addEventListener('click', () => { + this.$root.$emit(BV_SHOW_MODAL, CANCEL_JOBS_MODAL_ID, `#${buttonId}`); }); }, render(createElement) { - return createElement(STOP_JOBS_MODAL_ID, { + return createElement(CANCEL_JOBS_MODAL_ID, { props: { - url: stopJobsButton.dataset.url, + url: cancelJobsButton.dataset.url, + modalId: CANCEL_JOBS_MODAL_ID, }, }); }, @@ -33,4 +35,28 @@ function initJobs() { } } -initJobs(); +export function initAdminJobsApp() { + const containerEl = document.getElementById('admin-jobs-app'); + + if (!containerEl) return false; + + const { jobStatuses, emptyStateSvgPath, url } = containerEl.dataset; + + return new Vue({ + el: containerEl, + provide: { + url, + emptyStateSvgPath, + jobStatuses: JSON.parse(jobStatuses), + }, + render(createElement) { + return createElement(AdminJobsTableApp); + }, + }); +} + +if (gon.features.adminJobsVue) { + initAdminJobsApp(); +} else { + initJobs(); +} diff --git a/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue index c75c031b0b1..fa8f78839f3 100644 --- a/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue +++ b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue @@ -1,34 +1,31 @@ <script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlSearchBoxByType, - GlLoadingIcon, -} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import Api from '~/api'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __ } from '~/locale'; export default { i18n: { - dropdownHeader: __('Namespaces'), + headerText: __('Namespaces'), searchPlaceholder: __('Search for Namespace'), - anyNamespace: __('Any namespace'), + reset: __('Clear'), }, components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlLoadingIcon, - GlSearchBoxByType, + GlCollapsibleListbox, }, props: { - showAny: { - type: Boolean, + origSelectedId: { + type: String, + required: false, + default: '', + }, + origSelectedText: { + type: String, required: false, - default: false, + default: '', }, - placeholder: { + toggleTextPlaceholder: { type: String, required: false, default: __('Namespace'), @@ -42,56 +39,72 @@ export default { data() { return { namespaceOptions: [], - selectedNamespaceId: null, - selectedNamespace: null, + selectedNamespaceId: this.origSelectedId, + selectedNamespaceText: this.origSelectedText, searchTerm: '', isLoading: false, }; }, computed: { - selectedNamespaceName() { - if (this.selectedNamespaceId === null) { - return this.placeholder; - } - return this.selectedNamespace; + toggleText() { + return this.selectedNamespaceText || this.toggleTextPlaceholder; }, }, watch: { - searchTerm() { - this.fetchNamespaces(this.searchTerm); + selectedNamespaceId(val) { + if (!val) { + this.selectedNamespaceText = null; + } + + this.selectedNamespaceText = this.namespaceOptions.find(({ value }) => value === val)?.text; }, }, mounted() { this.fetchNamespaces(); }, methods: { - fetchNamespaces(filter) { + fetchNamespaces() { this.isLoading = true; this.namespaceOptions = []; - return Api.namespaces(filter, (namespaces) => { - this.namespaceOptions = namespaces; + + return Api.namespaces(this.searchTerm, (namespaces) => { + this.namespaceOptions = this.formatNamespaceOptions(namespaces); this.isLoading = false; }); }, - selectNamespace(key) { - this.selectedNamespaceId = this.namespaceOptions[key].id; - this.selectedNamespace = this.getNamespaceString(this.namespaceOptions[key]); - this.$emit('setNamespace', this.selectedNamespaceId); + formatNamespaceOptions(namespaces) { + if (!namespaces) { + return []; + } + + return namespaces.map((namespace) => { + return { + value: String(namespace.id), + text: this.getNamespaceString(namespace), + }; + }); }, - selectAnyNamespace() { - this.selectedNamespaceId = null; - this.selectedNamespace = null; - this.$emit('setNamespace', null); + selectNamespace(value) { + this.selectedNamespaceId = value; + this.$emit('setNamespace', this.selectedNamespaceId); }, getNamespaceString(namespace) { return `${namespace.kind}: ${namespace.full_path}`; }, + search: debounce(function debouncedSearch(searchQuery) { + this.searchTerm = searchQuery?.trim(); + this.fetchNamespaces(); + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), + onReset() { + this.selectedNamespaceId = null; + this.$emit('setNamespace', null); + }, }, }; </script> <template> - <div class="gl-display-flex"> + <div class="gl-display-flex gl-w-full"> <input v-if="fieldName" :name="fieldName" @@ -99,45 +112,19 @@ export default { type="hidden" data-testid="hidden-input" /> - <gl-dropdown - :text="selectedNamespaceName" - :header-text="$options.i18n.dropdownHeader" - toggle-class="dropdown-menu-toggle large" - data-testid="namespace-dropdown" - :right="true" - > - <template #header> - <gl-search-box-by-type - v-model.trim="searchTerm" - class="namespace-search-box" - debounce="250" - :placeholder="$options.i18n.searchPlaceholder" - /> - </template> - - <template v-if="showAny"> - <gl-dropdown-item @click="selectAnyNamespace"> - {{ $options.i18n.anyNamespace }} - </gl-dropdown-item> - <gl-dropdown-divider /> - </template> - - <gl-loading-icon v-if="isLoading" /> - - <gl-dropdown-item - v-for="(namespace, key) in namespaceOptions" - :key="namespace.id" - @click="selectNamespace(key)" - > - {{ getNamespaceString(namespace) }} - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox + :items="namespaceOptions" + :header-text="$options.i18n.headerText" + :reset-button-label="$options.i18n.reset" + :toggle-text="toggleText" + :search-placeholder="$options.i18n.searchPlaceholder" + :searching="isLoading" + :selected="selectedNamespaceId" + toggle-class="gl-w-full gl-flex-direction-column gl-align-items-stretch!" + searchable + @reset="onReset" + @search="search" + @select="selectNamespace" + /> </div> </template> - -<style scoped> -/* workaround position: relative imposed by .top-area .nav-controls */ -.namespace-search-box >>> input { - position: static; -} -</style> diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js index 3098d06510b..49ee89de772 100644 --- a/app/assets/javascripts/pages/admin/projects/index.js +++ b/app/assets/javascripts/pages/admin/projects/index.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import ProjectsList from '~/projects_list'; import NamespaceSelect from './components/namespace_select.vue'; @@ -12,16 +11,17 @@ function mountNamespaceSelect() { return false; } - const { showAny, fieldName, placeholder, updateLocation } = el.dataset; + const { fieldName, toggleTextPlaceholder, selectedId, selectedText, updateLocation } = el.dataset; return new Vue({ el, render(createComponent) { return createComponent(NamespaceSelect, { props: { - showAny: parseBoolean(showAny), fieldName, - placeholder, + toggleTextPlaceholder, + origSelectedId: selectedId, + origSelectedText: selectedText, }, on: { setNamespace(newNamespace) { diff --git a/app/assets/javascripts/pages/admin/runners/new/index.js b/app/assets/javascripts/pages/admin/runners/new/index.js new file mode 100644 index 00000000000..5048ad7b57a --- /dev/null +++ b/app/assets/javascripts/pages/admin/runners/new/index.js @@ -0,0 +1,3 @@ +import { initAdminNewRunner } from '~/ci/runner/admin_new_runner'; + +initAdminNewRunner(); diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js index 08c247a498b..2ca11e96f69 100644 --- a/app/assets/javascripts/pages/dashboard/issues/index.js +++ b/app/assets/javascripts/pages/dashboard/issues/index.js @@ -3,7 +3,7 @@ import { mountIssuesDashboardApp } from '~/issues/dashboard'; import initManualOrdering from '~/issues/manual_ordering'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -import projectSelect from '~/project_select'; +import { initNewResourceDropdown } from '~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown'; initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, @@ -11,7 +11,7 @@ initFilteredSearch({ useDefaultState: true, }); -projectSelect(); +initNewResourceDropdown(); initManualOrdering(); mountIssuesDashboardApp(); diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js index 1350837476b..a8c59ea6f3d 100644 --- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js +++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js @@ -2,7 +2,9 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -import projectSelect from '~/project_select'; +import { initNewResourceDropdown } from '~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown'; +import { RESOURCE_TYPE_MERGE_REQUEST } from '~/vue_shared/components/new_resource_dropdown/constants'; +import searchUserProjectsWithMergeRequestsEnabled from '~/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql'; addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys, true); @@ -12,4 +14,7 @@ initFilteredSearch({ useDefaultState: true, }); -projectSelect(); +initNewResourceDropdown({ + resourceType: RESOURCE_TYPE_MERGE_REQUEST, + query: searchUserProjectsWithMergeRequestsEnabled, +}); diff --git a/app/assets/javascripts/pages/dashboard/milestones/index/index.js b/app/assets/javascripts/pages/dashboard/milestones/index/index.js index b526fce6f7b..88061d9ca22 100644 --- a/app/assets/javascripts/pages/dashboard/milestones/index/index.js +++ b/app/assets/javascripts/pages/dashboard/milestones/index/index.js @@ -1,3 +1,12 @@ -import projectSelect from '~/project_select'; +import { initNewResourceDropdown } from '~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown'; +import { RESOURCE_TYPE_MILESTONE } from '~/vue_shared/components/new_resource_dropdown/constants'; +import searchUserGroupsAndProjects from '~/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql'; -projectSelect(); +initNewResourceDropdown({ + resourceType: RESOURCE_TYPE_MILESTONE, + query: searchUserGroupsAndProjects, + extractProjects: (data) => [ + ...(data?.user?.groups?.nodes ?? []), + ...(data?.projects?.nodes ?? []), + ], +}); diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index c5d62ae5daf..2fdf3c42935 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -133,10 +133,10 @@ export default class Todos { restoreBtn.classList.add('hidden'); doneBtn.classList.remove('hidden'); } else if (target === doneBtn) { - row.classList.add('done-reversible', 'gl-bg-gray-50', 'gl-border-gray-100'); + row.classList.add('done-reversible', 'gl-bg-gray-10', 'gl-border-gray-50'); restoreBtn.classList.remove('hidden'); } else if (target === restoreBtn) { - row.classList.remove('done-reversible', 'gl-bg-gray-50', 'gl-border-gray-100'); + row.classList.remove('done-reversible', 'gl-bg-gray-10', 'gl-border-gray-50'); doneBtn.classList.remove('hidden'); } else { row.parentNode.removeChild(row); @@ -147,17 +147,17 @@ export default class Todos { e.stopPropagation(); e.preventDefault(); - const target = e.currentTarget; - target.setAttribute('disabled', true); - target.classList.add('disabled'); + const { currentTarget } = e; + currentTarget.setAttribute('disabled', true); + currentTarget.classList.add('disabled'); - target.querySelector('.gl-spinner-container').classList.add('gl-mr-2'); + currentTarget.querySelector('.gl-spinner-container').classList.add('gl-mr-2'); - axios[target.dataset.method](target.dataset.href, { + axios[currentTarget.dataset.method](currentTarget.href, { ids: this.todo_ids, }) .then(({ data }) => { - this.updateAllState(target, data); + this.updateAllState(currentTarget, data); this.updateBadges(data); }) .catch(() => diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index fb685247bd4..dec06fe6f4d 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -2,10 +2,10 @@ import { GROUP_BADGE } from '~/badges/constants'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import initFilePickers from '~/file_pickers'; import initTransferGroupForm from '~/groups/init_transfer_group_form'; -import { initGroupSelects } from '~/vue_shared/components/group_select/init_group_selects'; +import { initGroupSelects } from '~/vue_shared/components/entity_select/init_group_selects'; +import { initProjectSelects } from '~/vue_shared/components/entity_select/init_project_selects'; import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; -import projectSelect from '~/project_select'; import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; import initConfirmDanger from '~/init_confirm_danger'; @@ -22,7 +22,8 @@ mountBadgeSettings(GROUP_BADGE); // Initialize Subgroups selector initGroupSelects(); -projectSelect(); +// Initialize project selectors +initProjectSelects(); initSearchSettings(); initCascadingSettingsLockPopovers(); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index ceda2c8fa17..1b3c7ba5a52 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -12,7 +12,6 @@ const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; const APP_OPTIONS = { [MEMBER_TYPES.user]: { tableFields: SHARED_FIELDS.concat(['source', 'activity']), - tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, tableSortableFields: [ 'account', 'granted', @@ -32,10 +31,6 @@ const APP_OPTIONS = { }, [MEMBER_TYPES.group]: { tableFields: SHARED_FIELDS.concat(['source', 'granted']), - tableAttrs: { - table: { 'data-qa-selector': 'groups_list' }, - tr: { 'data-qa-selector': 'group_row' }, - }, requestFormatter: groupLinkRequestFormatter, filteredSearchBar: { show: true, diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index bf0147ca885..2cf75fcf666 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -3,7 +3,9 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered import { FILTERED_SEARCH } from '~/filtered_search/constants'; import { initBulkUpdateSidebar } from '~/issuable'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -import projectSelect from '~/project_select'; +import { initNewResourceDropdown } from '~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown'; +import { RESOURCE_TYPE_MERGE_REQUEST } from '~/vue_shared/components/new_resource_dropdown/constants'; +import searchUserGroupProjectsWithMergeRequestsEnabled from '~/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql'; const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_'; @@ -16,4 +18,8 @@ initFilteredSearch({ useDefaultState: true, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, }); -projectSelect(); +initNewResourceDropdown({ + resourceType: RESOURCE_TYPE_MERGE_REQUEST, + query: searchUserGroupProjectsWithMergeRequestsEnabled, + extractProjects: (data) => data?.group?.projects?.nodes, +}); diff --git a/app/assets/javascripts/pages/groups/usage_quotas/index.js b/app/assets/javascripts/pages/groups/usage_quotas/index.js new file mode 100644 index 00000000000..dab2d0b17d2 --- /dev/null +++ b/app/assets/javascripts/pages/groups/usage_quotas/index.js @@ -0,0 +1,3 @@ +import initUsageQuotas from '~/usage_quotas'; + +initUsageQuotas(); diff --git a/app/assets/javascripts/pages/profiles/saved_replies/index.js b/app/assets/javascripts/pages/profiles/saved_replies/index.js new file mode 100644 index 00000000000..ef227b82172 --- /dev/null +++ b/app/assets/javascripts/pages/profiles/saved_replies/index.js @@ -0,0 +1,3 @@ +import { initSavedReplies } from '~/saved_replies'; + +initSavedReplies(); diff --git a/app/assets/javascripts/pages/projects/airflow/dags/index/index.js b/app/assets/javascripts/pages/projects/airflow/dags/index/index.js new file mode 100644 index 00000000000..1d7cf4a5b8e --- /dev/null +++ b/app/assets/javascripts/pages/projects/airflow/dags/index/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import AirflowDags from '~/airflow/dags/components/dags.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +const initShowDags = () => { + const element = document.querySelector('#js-show-airflow-dags'); + if (!element) { + return null; + } + + const dags = JSON.parse(element.dataset.dags); + const pagination = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pagination)); + + return new Vue({ + el: element, + render(h) { + return h(AirflowDags, { + props: { + dags, + pagination, + }, + }); + }, + }); +}; + +initShowDags(); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index 46704d96552..667fd89af55 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -16,6 +16,7 @@ import syntaxHighlight from '~/syntax_highlight'; import ZenMode from '~/zen_mode'; import '~/sourcegraph/load'; import DiffStats from '~/diffs/components/diff_stats.vue'; +import { initReportAbuse } from '~/projects/report_abuse'; const hasPerfBar = document.querySelector('.with-performance-bar'); const performanceHeight = hasPerfBar ? 35 : 0; @@ -26,6 +27,7 @@ new ShortcutsNavigation(); initCommitBoxInfo(); initDeprecatedNotes(); +initReportAbuse(); const loadDiffStats = () => { const diffStatsElements = document.querySelectorAll('#js-diff-stats'); @@ -67,6 +69,7 @@ if (filesContainer.length) { handleLocationHash(); new Diff(); loadDiffStats(); + initReportAbuse(); }) .catch(() => { createAlert({ message: __('An error occurred while retrieving diff files') }); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index c0eb2a8fd77..82035008459 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -10,6 +10,8 @@ import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; import UserCallout from '~/user_callout'; import initTopicsTokenSelector from '~/projects/settings/topics'; +import { initProjectSelects } from '~/vue_shared/components/entity_select/init_project_selects'; +import initPruneObjectsButton from '~/projects/prune_objects_button'; import initProjectPermissionsSettings from '../shared/permissions'; import initProjectLoadingSpinner from '../shared/save_project_loader'; @@ -17,6 +19,7 @@ initFilePickers(); initConfirmDanger(); initSettingsPanels(); initProjectDeleteButton(); +initPruneObjectsButton(); mountBadgeSettings(PROJECT_BADGE); new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new @@ -30,3 +33,4 @@ dirtySubmitFactory(document.querySelectorAll('.js-general-settings-form, .js-mr- initSearchSettings(); initTopicsTokenSelector(); +initProjectSelects(); diff --git a/app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js b/app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js new file mode 100644 index 00000000000..9a3bb25de70 --- /dev/null +++ b/app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import { s__ } from '~/locale'; +import Translate from '~/vue_shared/translate'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { generateRefDestinationPath } from './ref_switcher_utils'; + +Vue.use(Translate); + +const REF_SWITCH_HEADER = s__('FindFile|Switch branch/tag'); + +export default () => { + const el = document.getElementById('js-blob-ref-switcher'); + if (!el) return false; + + const { projectId, ref, namespace } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(RefSelector, { + props: { + projectId, + value: ref, + translations: { + dropdownHeader: REF_SWITCH_HEADER, + searchPlaceholder: REF_SWITCH_HEADER, + }, + }, + on: { + input(selected) { + visitUrl(generateRefDestinationPath(selected, namespace)); + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js b/app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js new file mode 100644 index 00000000000..5fecd024f1a --- /dev/null +++ b/app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js @@ -0,0 +1,28 @@ +import { joinPaths } from '~/lib/utils/url_utility'; + +/** + * Generates a ref destination url based on the selected ref and current url. + * @param {string} selectedRef - The selected ref from the ref dropdown. + * @param {string} namespace - The destination namespace for the path. + */ +export function generateRefDestinationPath(selectedRef, namespace) { + if (!selectedRef || !namespace) { + return window.location.href; + } + + const { pathname } = window.location; + const encodedHash = '%23'; + + const [projectRootPath] = pathname.split(namespace); + + const destinationPath = joinPaths( + projectRootPath, + namespace, + encodeURI(selectedRef).replace(/#/g, encodedHash), + ); + + const newURL = new URL(window.location); + newURL.pathname = destinationPath; + + return newURL.href; +} diff --git a/app/assets/javascripts/pages/projects/find_file/show/index.js b/app/assets/javascripts/pages/projects/find_file/show/index.js index f47888f0cb8..e207df2434b 100644 --- a/app/assets/javascripts/pages/projects/find_file/show/index.js +++ b/app/assets/javascripts/pages/projects/find_file/show/index.js @@ -1,7 +1,9 @@ import $ from 'jquery'; import ShortcutsFindFile from '~/behaviors/shortcuts/shortcuts_find_file'; import ProjectFindFile from '~/projects/project_find_file'; +import InitBlobRefSwitcher from '../ref_switcher'; +InitBlobRefSwitcher(); const findElement = document.querySelector('.js-file-finder'); const projectFindFile = new ProjectFindFile($('.file-finder-holder'), { url: findElement.dataset.fileFindUrl, diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index 2028af8b8f0..85fe3477d7c 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -16,7 +16,7 @@ import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import csrf from '~/lib/utils/csrf'; import { redirectTo } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import validation from '~/vue_shared/directives/validation'; import { VISIBILITY_LEVEL_PRIVATE_STRING, @@ -25,8 +25,24 @@ import { VISIBILITY_LEVELS_STRING_TO_INTEGER, VISIBILITY_LEVELS_INTEGER_TO_STRING, } from '~/visibility_level/constants'; +import { START_RULE, CONTAINS_RULE } from '~/projects/project_name_rules'; import ProjectNamespace from './project_namespace.vue'; +const feedbackMap = { + valueMissing: { + isInvalid: (el) => el.validity?.valueMissing, + message: __('Please fill out this field.'), + }, + nameStartPattern: { + isInvalid: (el) => el.validity?.patternMismatch && !START_RULE.reg.test(el.value), + message: START_RULE.msg, + }, + nameContainsPattern: { + isInvalid: (el) => el.validity?.patternMismatch && !CONTAINS_RULE.reg.test(el.value), + message: CONTAINS_RULE.msg, + }, +}; + const initFormField = ({ value, required = true, skipValidation = false }) => ({ value, required, @@ -48,7 +64,7 @@ export default { ProjectNamespace, }, directives: { - validation: validation(), + validation: validation(feedbackMap), }, inject: { newGroupPath: { @@ -109,6 +125,15 @@ export default { }; }, computed: { + projectNameDescription() { + if (this.form.fields.name.state === false) { + return null; + } + + return s__( + 'ProjectsNew|Must start with a lowercase or uppercase letter, digit, emoji, or underscore. Can also contain dots, pluses, dashes, or spaces.', + ); + }, projectVisibilityLevel() { return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility]; }, @@ -248,6 +273,7 @@ export default { }, }, csrf, + projectNamePattern: `(${START_RULE.reg.source})|(${CONTAINS_RULE.reg.source})`, }; </script> @@ -257,8 +283,10 @@ export default { <gl-form-group :label="__('Project name')" + :description="projectNameDescription" label-for="fork-name" :invalid-feedback="form.fields.name.feedback" + data-testid="fork-name-form-group" > <gl-form-input id="fork-name" @@ -268,6 +296,7 @@ export default { data-testid="fork-name-input" :state="form.fields.name.state" required + :pattern="$options.projectNamePattern" /> </gl-form-group> diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index 65e7f48ed24..10c794c9ba2 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -2,6 +2,9 @@ import { GlColumnChart } from '@gitlab/ui/dist/charts'; import Vue from 'vue'; import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; import { __ } from '~/locale'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; +import RefSelector from '~/ref/components/ref_selector.vue'; import CodeCoverage from '../components/code_coverage.vue'; import SeriesDataMixin from './series_data_mixin'; @@ -13,6 +16,7 @@ waitForCSSLoaded(() => { const monthContainer = document.getElementById('js-month-chart'); const weekdayContainer = document.getElementById('js-weekday-chart'); const hourContainer = document.getElementById('js-hour-chart'); + const branchSelector = document.getElementById('js-project-graph-ref-switcher'); const LANGUAGE_CHART_HEIGHT = 300; const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => { if (firstDayOfWeek === 0) { @@ -173,4 +177,38 @@ waitForCSSLoaded(() => { }); }, }); + + const { projectId, projectBranch, graphPath } = branchSelector.dataset; + + const GRAPHS_PATH_REGEX = /^(.*?)\/-\/graphs/g; + const graphsPathPrefix = graphPath.match(GRAPHS_PATH_REGEX)?.[0]; + if (!graphsPathPrefix) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Path is not correct'); + } + + // eslint-disable-next-line no-new + new Vue({ + el: branchSelector, + name: 'RefSelector', + render(createComponent) { + return createComponent(RefSelector, { + props: { + enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS], + value: projectBranch, + translations: { + dropdownHeader: __('Switch branch/tag'), + searchPlaceholder: __('Search branches and tags'), + }, + projectId, + }, + class: 'gl-w-20', + on: { + input(selected) { + visitUrl(`${graphsPathPrefix}/${encodeURIComponent(selected)}/charts`); + }, + }, + }); + }, + }); }); diff --git a/app/assets/javascripts/pages/projects/hooks/index.js b/app/assets/javascripts/pages/projects/hooks/index.js index 9e559354205..f25547f9982 100644 --- a/app/assets/javascripts/pages/projects/hooks/index.js +++ b/app/assets/javascripts/pages/projects/hooks/index.js @@ -1,7 +1,8 @@ import initSearchSettings from '~/search_settings'; -import initWebhookForm from '~/webhooks'; +import initWebhookForm, { initHookTestDropdowns } from '~/webhooks'; import { initPushEventsEditForm } from '~/webhooks/webhook'; initSearchSettings(); initWebhookForm(); initPushEventsEditForm(); +initHookTestDropdowns(); diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 37cf345fe77..1075241e172 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,7 +1,5 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import initTerraformNotification from '~/projects/terraform_notification'; import Project from './project'; new Project(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new -initTerraformNotification(); diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue deleted file mode 100644 index 693dc6a15ad..00000000000 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue +++ /dev/null @@ -1,15 +0,0 @@ -<script> -import { s__ } from '~/locale'; - -export default { - name: 'IncludedInTrialIndicator', - i18n: { - trialOnly: s__('LearnGitlab|- Included in trial'), - }, -}; -</script> -<template> - <span class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only"> - {{ $options.i18n.trialOnly }} - </span> -</template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue deleted file mode 100644 index 54e15b6552c..00000000000 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue +++ /dev/null @@ -1,146 +0,0 @@ -<script> -import { GlProgressBar, GlSprintf, GlAlert } from '@gitlab/ui'; -import eventHub from '~/invite_members/event_hub'; -import { s__ } from '~/locale'; -import { getCookie, removeCookie, parseBoolean } from '~/lib/utils/common_utils'; -import { ACTION_LABELS, ACTION_SECTIONS, INVITE_MODAL_OPEN_COOKIE } from '../constants'; -import LearnGitlabSectionCard from './learn_gitlab_section_card.vue'; - -export default { - components: { GlProgressBar, GlSprintf, GlAlert, LearnGitlabSectionCard }, - i18n: { - title: s__('LearnGitLab|Learn GitLab'), - description: s__( - 'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.', - ), - percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`), - successfulInvitations: s__( - "LearnGitLab|Your team is growing! You've successfully invited new team members to the %{projectName} project.", - ), - }, - props: { - actions: { - required: true, - type: Object, - }, - sections: { - required: true, - type: Object, - }, - project: { - required: true, - type: Object, - }, - }, - data() { - return { - showSuccessfulInvitationsAlert: false, - actionsData: this.actions, - }; - }, - actionSections: Object.keys(ACTION_SECTIONS), - computed: { - maxValue() { - return Object.keys(this.actionsData).length; - }, - progressValue() { - return Object.values(this.actionsData).filter((a) => a.completed).length; - }, - progressPercentage() { - return Math.round((this.progressValue / this.maxValue) * 100); - }, - }, - mounted() { - if (this.getCookieForInviteMembers()) { - this.openInviteMembersModal('celebrate'); - } - - eventHub.$on('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert); - }, - beforeDestroy() { - eventHub.$off('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert); - }, - methods: { - getCookieForInviteMembers() { - const value = parseBoolean(getCookie(INVITE_MODAL_OPEN_COOKIE)); - - removeCookie(INVITE_MODAL_OPEN_COOKIE); - - return value; - }, - openInviteMembersModal(mode) { - eventHub.$emit('openModal', { mode, source: 'learn-gitlab' }); - }, - handleShowSuccessfulInvitationsAlert() { - this.showSuccessfulInvitationsAlert = true; - this.markActionAsCompleted('userAdded'); - }, - actionsFor(section) { - const actions = Object.fromEntries( - Object.entries(this.actionsData).filter( - ([action]) => ACTION_LABELS[action].section === section, - ), - ); - return actions; - }, - svgFor(section) { - return this.sections[section].svg; - }, - markActionAsCompleted(completedAction) { - Object.keys(this.actionsData).forEach((action) => { - if (action === completedAction) { - this.actionsData[action].completed = true; - this.modifySidebarPercentage(); - } - }); - }, - modifySidebarPercentage() { - const el = document.querySelector('.sidebar-top-level-items .active .count'); - el.textContent = `${this.progressPercentage}%`; - }, - }, -}; -</script> -<template> - <div> - <gl-alert - v-if="showSuccessfulInvitationsAlert" - class="gl-mt-5" - @dismiss="showSuccessfulInvitationsAlert = false" - > - <gl-sprintf :message="$options.i18n.successfulInvitations"> - <template #projectName> - <strong>{{ project.name }}</strong> - </template> - </gl-sprintf> - </gl-alert> - <div class="row"> - <div class="gl-mb-7 gl-ml-5"> - <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> - <p class="gl-text-gray-700 gl-mb-0">{{ $options.i18n.description }}</p> - </div> - </div> - <div class="gl-mb-3"> - <p class="gl-text-gray-500 gl-mb-2" data-testid="completion-percentage"> - <gl-sprintf :message="$options.i18n.percentageCompleted"> - <template #percentage>{{ progressPercentage }}</template> - <template #percentSymbol>%</template> - </gl-sprintf> - </p> - <gl-progress-bar :value="progressValue" :max="maxValue" /> - </div> - <div class="row"> - <div - v-for="section in $options.actionSections" - :key="section" - class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4" - > - <learn-gitlab-section-card - :section="section" - :svg="svgFor(section)" - :actions="actionsFor(section)" - /> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue deleted file mode 100644 index e8f0e6c47ee..00000000000 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue +++ /dev/null @@ -1,56 +0,0 @@ -<script> -import { GlCard } from '@gitlab/ui'; -import { ACTION_LABELS, ACTION_SECTIONS } from '../constants'; - -import LearnGitlabSectionLink from './learn_gitlab_section_link.vue'; - -export default { - name: 'LearnGitlabSectionCard', - components: { GlCard, LearnGitlabSectionLink }, - i18n: { - ...ACTION_SECTIONS, - }, - props: { - section: { - required: true, - type: String, - }, - svg: { - required: true, - type: String, - }, - actions: { - required: true, - type: Object, - }, - }, - computed: { - sortedActions() { - return Object.entries(this.actions).sort( - (a1, a2) => ACTION_LABELS[a1[0]].position - ACTION_LABELS[a2[0]].position, - ); - }, - }, -}; -</script> -<template> - <gl-card - class="gl-pt-0 h-100" - header-class="gl-bg-white gl-border-0 gl-pb-0" - body-class="gl-pt-0" - > - <template #header> - <img :src="svg" /> - <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n[section].title }}</h2> - <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n[section].description }}</p> - </template> - <template #default> - <learn-gitlab-section-link - v-for="[action, value] in sortedActions" - :key="action" - :action="action" - :value="value" - /> - </template> - </gl-card> -</template> 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 deleted file mode 100644 index d9b0dbbb9b0..00000000000 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue +++ /dev/null @@ -1,151 +0,0 @@ -<script> -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'; -import { s__, __ } from '~/locale'; -import { ACTION_LABELS } from '../constants'; -import IncludedInTrialIndicator from './included_in_trial_indicator.vue'; - -export default { - name: 'LearnGitlabSectionLink', - components: { - GlLink, - GlIcon, - GlButton, - GlPopover, - GitlabExperiment, - IncludedInTrialIndicator, - }, - directives: { - GlTooltip, - }, - i18n: { - contactAdmin: s__('LearnGitlab|Contact your administrator to enable this action.'), - viewAdminList: s__('LearnGitlab|View administrator list'), - watchHow: __('Watch how'), - }, - props: { - action: { - required: true, - type: String, - }, - value: { - required: true, - type: Object, - }, - }, - data() { - return { - popoverId: uniqueId('contact-admin-'), - }; - }, - computed: { - showInviteModalLink() { - return ( - this.action === 'userAdded' && isExperimentVariant('invite_for_help_continuous_onboarding') - ); - }, - openInNewTab() { - return ACTION_LABELS[this.action]?.openInNewTab === true || this.value.openInNewTab === true; - }, - popoverText() { - return this.value.message || this.$options.i18n.contactAdmin; - }, - }, - methods: { - openModal() { - eventHub.$emit('openModal', { source: 'learn_gitlab' }); - }, - actionLabelValue(value) { - return ACTION_LABELS[this.action][value]; - }, - }, -}; -</script> -<template> - <div class="gl-mb-4"> - <div class="flex align-items-center"> - <span v-if="value.completed" class="gl-text-green-500"> - <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" /> - {{ actionLabelValue('title') }} - <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" /> - </span> - <div v-else-if="showInviteModalLink"> - <gl-link - data-track-action="click_link" - :data-track-label="actionLabelValue('trackLabel')" - data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding" - data-testid="invite-for-help-continuous-onboarding-experiment-link" - @click="openModal" - >{{ actionLabelValue('title') }}</gl-link - > - - <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" /> - </div> - <div v-else-if="value.enabled"> - <gl-link - :target="openInNewTab ? '_blank' : '_self'" - :href="value.url" - data-testid="uncompleted-learn-gitlab-link" - data-qa-selector="uncompleted_learn_gitlab_link" - data-track-action="click_link" - :data-track-label="actionLabelValue('trackLabel')" - >{{ actionLabelValue('title') }}</gl-link - > - - <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" /> - </div> - <template v-else> - <div data-testid="disabled-learn-gitlab-link">{{ actionLabelValue('title') }}</div> - <gl-button - :id="popoverId" - category="tertiary" - icon="question-o" - class="ml-auto" - :aria-label="popoverText" - size="small" - data-testid="contact-admin-popover-trigger" - /> - <gl-popover - :target="popoverId" - placement="top" - triggers="hover focus" - data-testid="contact-admin-popover" - > - <p>{{ popoverText }}</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> - <gl-button - v-if="actionLabelValue('videoTutorial')" - v-gl-tooltip - category="tertiary" - icon="live-preview" - :title="$options.i18n.watchHow" - :aria-label="$options.i18n.watchHow" - :href="actionLabelValue('videoTutorial')" - target="_blank" - class="ml-auto" - size="small" - data-testid="video-tutorial-link" - data-track-action="click_video_link" - :data-track-label="actionLabelValue('trackLabel')" - data-track-property="Growth::Conversion::Experiment::LearnGitLab" - data-track-experiment="video_tutorials_continuous_onboarding" - /> - </template> - </gitlab-experiment> - </div> - </div> -</template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js deleted file mode 100644 index cb1a0302d91..00000000000 --- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js +++ /dev/null @@ -1,133 +0,0 @@ -import { s__ } from '~/locale'; - -export const ACTION_LABELS = { - gitWrite: { - title: s__('LearnGitLab|Create a repository'), - actionLabel: s__('LearnGitLab|Create a repository'), - description: s__('LearnGitLab|Create or import your first repository into your new project.'), - trackLabel: 'create_a_repository', - section: 'workspace', - position: 1, - }, - userAdded: { - title: s__('LearnGitLab|Invite your colleagues'), - actionLabel: s__('LearnGitLab|Invite your colleagues'), - description: s__( - 'LearnGitLab|GitLab works best as a team. Invite your colleague to enjoy all features.', - ), - trackLabel: 'invite_your_colleagues', - section: 'workspace', - position: 0, - }, - pipelineCreated: { - title: s__("LearnGitLab|Set up your first project's CI/CD"), - actionLabel: s__('LearnGitLab|Set up CI/CD'), - description: s__('LearnGitLab|Save time by automating your integration and deployment tasks.'), - trackLabel: 'set_up_your_first_project_s_ci_cd', - section: 'workspace', - position: 2, - }, - trialStarted: { - title: s__('LearnGitLab|Start a free trial of GitLab Ultimate'), - actionLabel: s__('LearnGitLab|Try GitLab Ultimate for free'), - description: s__('LearnGitLab|Try all GitLab features for 30 days, no credit card required.'), - trackLabel: 'start_a_free_trial_of_gitlab_ultimate', - section: 'workspace', - position: 3, - openInNewTab: true, - }, - codeOwnersEnabled: { - title: s__('LearnGitLab|Add code owners'), - actionLabel: s__('LearnGitLab|Add code owners'), - description: s__( - 'LearnGitLab|Prevent unexpected changes to important assets by assigning ownership of files and paths.', - ), - trackLabel: 'add_code_owners', - trialRequired: true, - section: 'workspace', - position: 4, - openInNewTab: true, - videoTutorial: 'https://vimeo.com/670896787', - }, - requiredMrApprovalsEnabled: { - title: s__('LearnGitLab|Enable require merge approvals'), - actionLabel: s__('LearnGitLab|Enable require merge approvals'), - description: s__('LearnGitLab|Route code reviews to the right reviewers, every time.'), - trackLabel: 'enable_require_merge_approvals', - trialRequired: true, - section: 'workspace', - position: 5, - openInNewTab: true, - videoTutorial: 'https://vimeo.com/670904904', - }, - mergeRequestCreated: { - title: s__('LearnGitLab|Submit a merge request (MR)'), - actionLabel: s__('LearnGitLab|Submit a merge request (MR)'), - description: s__('LearnGitLab|Review and edit proposed changes to source code.'), - trackLabel: 'submit_a_merge_request_mr', - section: 'plan', - position: 1, - }, - issueCreated: { - title: s__('LearnGitLab|Create an issue'), - actionLabel: s__('LearnGitLab|Create an issue'), - description: s__( - 'LearnGitLab|Create/import issues (tickets) to collaborate on ideas and plan work.', - ), - trackLabel: 'create_an_issue', - section: 'plan', - position: 0, - }, - securityScanEnabled: { - title: s__('LearnGitLab|Run a Security scan using CI/CD'), - actionLabel: s__('LearnGitLab|Run a Security scan using CI/CD'), - description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'), - trackLabel: 'run_a_security_scan_using_ci_cd', - section: 'deploy', - position: 1, - }, - licenseScanningRun: { - title: s__('LearnGitLab|Scan dependencies for licenses'), - trackLabel: 'scan_dependencies_for_licenses', - trialRequired: true, - section: 'deploy', - position: 2, - }, - secureDependencyScanningRun: { - title: s__('LearnGitLab|Scan dependencies for vulnerabilities'), - trackLabel: 'scan_dependencies_for_vulnerabilities', - trialRequired: true, - section: 'deploy', - position: 3, - }, - secureDastRun: { - title: s__('LearnGitLab|Analyze your application for vulnerabilities with DAST'), - trackLabel: 'analyze_your_application_for_vulnerabilities_with_dast', - trialRequired: true, - section: 'deploy', - position: 4, - }, -}; - -export const ACTION_SECTIONS = { - workspace: { - title: s__('LearnGitLab|Set up your workspace'), - description: s__( - "LearnGitLab|Complete these tasks first so you can enjoy GitLab's features to their fullest:", - ), - }, - plan: { - title: s__('LearnGitLab|Plan and execute'), - description: s__( - 'LearnGitLab|Create a workflow for your new workspace, and learn how GitLab features work together:', - ), - }, - deploy: { - title: s__('LearnGitLab|Deploy'), - description: s__( - 'LearnGitLab|Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:', - ), - }, -}; - -export const INVITE_MODAL_OPEN_COOKIE = 'confetti_post_signup'; diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js deleted file mode 100644 index af4a6f8a0c9..00000000000 --- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js +++ /dev/null @@ -1,31 +0,0 @@ -import Vue from 'vue'; -import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; -import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import LearnGitlab from '../components/learn_gitlab.vue'; - -function initLearnGitlab() { - const el = document.getElementById('js-learn-gitlab-app'); - - if (!el) { - return false; - } - - const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions)); - const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections)); - const project = convertObjectPropsToCamelCase(JSON.parse(el.dataset.project)); - - return new Vue({ - el, - render(createElement) { - return createElement(LearnGitlab, { - props: { actions, sections, project }, - }); - }, - }); -} - -initInviteMembersModal(); -initInviteMembersTrigger(); - -initLearnGitlab(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js deleted file mode 100644 index 653f903c6d1..00000000000 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js +++ /dev/null @@ -1,66 +0,0 @@ -import $ from 'jquery'; -import axios from '~/lib/utils/axios_utils'; -import { localTimeAgo } from '~/lib/utils/datetime_utility'; -import initCompareAutocomplete from './compare_autocomplete'; -import initTargetProjectDropdown from './target_project_dropdown'; - -const updateCommitList = (url, $emptyState, $loadingIndicator, $commitList, params) => { - $emptyState.hide(); - $loadingIndicator.show(); - $commitList.empty(); - - return axios - .get(url, { - params, - }) - .then(({ data }) => { - $loadingIndicator.hide(); - $commitList.html(data); - localTimeAgo($commitList.get(0).querySelectorAll('.js-timeago')); - - if (!data) { - $emptyState.show(); - } - }); -}; - -export default (mrNewCompareNode) => { - const { sourceBranchUrl, targetBranchUrl } = mrNewCompareNode.dataset; - - if (!window.gon?.features?.mrCompareDropdowns) { - initTargetProjectDropdown(); - } - - const updateSourceBranchCommitList = () => - updateCommitList( - sourceBranchUrl, - $(mrNewCompareNode).find('.js-source-commit-empty'), - $(mrNewCompareNode).find('.js-source-loading'), - $(mrNewCompareNode).find('.mr_source_commit'), - { - ref: $(mrNewCompareNode).find("input[name='merge_request[source_branch]']").val(), - }, - ); - const updateTargetBranchCommitList = () => - updateCommitList( - targetBranchUrl, - $(mrNewCompareNode).find('.js-target-commit-empty'), - $(mrNewCompareNode).find('.js-target-loading'), - $(mrNewCompareNode).find('.mr_target_commit'), - { - target_project_id: $(mrNewCompareNode) - .find("input[name='merge_request[target_project_id]']") - .val(), - ref: $(mrNewCompareNode).find("input[name='merge_request[target_branch]']").val(), - }, - ); - initCompareAutocomplete('branches', ($dropdown) => { - if ($dropdown.is('.js-target-branch')) { - updateTargetBranchCommitList(); - } else if ($dropdown.is('.js-source-branch')) { - updateSourceBranchCommitList(); - } - }); - updateSourceBranchCommitList(); - updateTargetBranchCommitList(); -}; diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js deleted file mode 100644 index 65942464e2b..00000000000 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js +++ /dev/null @@ -1,91 +0,0 @@ -/* eslint-disable func-names */ - -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { createAlert } from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; -import { __ } from '~/locale'; -import { fixTitle } from '~/tooltips'; - -export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) { - $('.js-compare-dropdown').each(function () { - const $dropdown = $(this); - const selected = $dropdown.data('selected'); - const defaultText = $dropdown.data('defaultText').trim(); - const $dropdownContainer = $dropdown.closest('.dropdown'); - const $fieldInput = $(`input[name="${$dropdown.data('fieldName')}"]`, $dropdownContainer); - const $filterInput = $('input[type="search"]', $dropdownContainer); - initDeprecatedJQueryDropdown($dropdown, { - data(term, callback) { - const params = { - ref: $dropdown.data('ref'), - search: term, - }; - - if (limitTo) { - params.find = limitTo; - } - - axios - .get($dropdown.data('refsUrl'), { - params, - }) - .then(({ data }) => { - if (limitTo) { - callback(data[capitalizeFirstCharacter(limitTo)] || []); - } else { - callback(data); - } - }) - .catch(() => - createAlert({ - message: __('Error fetching refs'), - }), - ); - }, - selectable: true, - filterable: true, - filterRemote: Boolean($dropdown.data('refsUrl')), - fieldName: $dropdown.data('fieldName'), - filterInput: 'input[type="search"]', - renderRow(ref) { - const link = $('<a />') - .attr('href', '#') - .addClass(ref === selected ? 'is-active' : '') - .text(ref) - .attr('data-ref', ref); - if (ref.header != null) { - return $('<li />').addClass('dropdown-header').text(ref.header); - } - return $('<li />').append(link); - }, - id(obj, $el) { - return $el.attr('data-ref'); - }, - toggleLabel(obj, $el) { - if ($el.hasClass('is-active')) { - return $el.text().trim(); - } - - return defaultText; - }, - clicked: () => clickHandler($dropdown), - }); - $filterInput.on('keyup', (e) => { - const keyCode = e.keyCode || e.which; - if (keyCode !== 13) return; - const text = $filterInput.val(); - $fieldInput.val(text); - $('.dropdown-toggle-text', $dropdown).text(text); - $dropdownContainer.removeClass('open'); - }); - - $dropdownContainer.on('click', '.dropdown-content a', (e) => { - $dropdown.prop('title', e.target.text.replace(/_+?/g, '-')); - if ($dropdown.hasClass('has-tooltip')) { - fixTitle($dropdown); - } - }); - }); -} diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js index b3868653d6a..2718765ee23 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js @@ -1,35 +1,78 @@ -import $ from 'jquery'; import Vue from 'vue'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; import MergeRequest from '~/merge_request'; -import TargetProjectDropdown from '~/merge_requests/components/target_project_dropdown.vue'; -import initCompare from './compare'; +import CompareApp from '~/merge_requests/components/compare_app.vue'; +import { __ } from '~/locale'; const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare'); if (mrNewCompareNode) { - initCompare(mrNewCompareNode); - - const el = document.getElementById('js-target-project-dropdown'); - const { targetProjectsPath, currentProject } = el.dataset; + const targetCompareEl = document.getElementById('js-target-project-dropdown'); + const sourceCompareEl = document.getElementById('js-source-project-dropdown'); + const compareEl = document.querySelector('.js-merge-request-new-compare'); // eslint-disable-next-line no-new new Vue({ - el, - name: 'TargetProjectDropdown', + el: sourceCompareEl, + name: 'SourceCompareApp', provide: { - targetProjectsPath, - currentProject: JSON.parse(currentProject), + currentProject: JSON.parse(sourceCompareEl.dataset.currentProject), + currentBranch: JSON.parse(sourceCompareEl.dataset.currentBranch), + branchCommitPath: compareEl.dataset.sourceBranchUrl, + inputs: { + project: { + id: 'merge_request_source_project_id', + name: 'merge_request[source_project_id]', + }, + branch: { + id: 'merge_request_source_branch', + name: 'merge_request[source_branch]', + }, + }, + i18n: { + projectHeaderText: __('Select source project'), + branchHeaderText: __('Select source branch'), + }, + toggleClass: { + project: 'js-source-project', + branch: 'js-source-branch gl-font-monospace', + }, + branchQaSelector: 'source_branch_dropdown', }, render(h) { - return h(TargetProjectDropdown, { - on: { - 'project-selected': function projectSelectedFunction(refsUrl) { - const $targetBranchDropdown = $('.js-target-branch'); - $targetBranchDropdown.data('refsUrl', refsUrl); - $targetBranchDropdown.data('deprecatedJQueryDropdown').clearMenu(); - }, + return h(CompareApp); + }, + }); + + // eslint-disable-next-line no-new + new Vue({ + el: targetCompareEl, + name: 'TargetCompareApp', + provide: { + currentProject: JSON.parse(targetCompareEl.dataset.currentProject), + currentBranch: JSON.parse(targetCompareEl.dataset.currentBranch), + projectsPath: targetCompareEl.dataset.targetProjectsPath, + branchCommitPath: compareEl.dataset.targetBranchUrl, + inputs: { + project: { + id: 'merge_request_target_project_id', + name: 'merge_request[target_project_id]', }, - }); + branch: { + id: 'merge_request_target_branch', + name: 'merge_request[target_branch]', + }, + }, + i18n: { + projectHeaderText: __('Select target project'), + branchHeaderText: __('Select target branch'), + }, + toggleClass: { + project: 'js-target-project', + branch: 'js-target-branch gl-font-monospace', + }, + }, + render(h) { + return h(CompareApp); }, }); } else { diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js deleted file mode 100644 index e9f0e008435..00000000000 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js +++ /dev/null @@ -1,23 +0,0 @@ -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; - -export default () => { - const $targetProjectDropdown = $('.js-target-project'); - initDeprecatedJQueryDropdown($targetProjectDropdown, { - selectable: true, - fieldName: $targetProjectDropdown.data('fieldName'), - filterable: true, - id(obj, $el) { - return $el.data('id'); - }, - toggleLabel(obj, $el) { - return $el.text().trim(); - }, - clicked({ $el }) { - $('.mr_target_commit').empty(); - const $targetBranchDropdown = $('.js-target-branch'); - $targetBranchDropdown.data('refsUrl', $el.data('refsUrl')); - $targetBranchDropdown.data('deprecatedJQueryDropdown').clearMenu(); - }, - }); -}; diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index b3a09cc0be3..af75c05b300 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -5,7 +5,6 @@ import { FILTERED_SEARCH } from '~/filtered_search/constants'; import { initBulkUpdateSidebar, initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; import { ISSUABLE_INDEX } from '~/issuable/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -import UsersSelect from '~/users_select'; initBulkUpdateSidebar(ISSUABLE_INDEX.MERGE_REQUEST); @@ -18,7 +17,6 @@ initFilteredSearch({ useDefaultState: true, }); -new UsersSelect(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new initIssuableByEmail(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index f0a955e5360..91394755367 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,5 +1,5 @@ import initNotesApp from '~/mr_notes/init_notes'; -import { initReportAbuse } from '~/projects/merge_requests'; +import { initReportAbuse } from '~/projects/report_abuse'; import { initMrPage } from '../page'; initMrPage(); diff --git a/app/assets/javascripts/pages/projects/ml/candidates/show/index.js b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js index c1acef5ac13..fee6258eddc 100644 --- a/app/assets/javascripts/pages/projects/ml/candidates/show/index.js +++ b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js @@ -1,27 +1,4 @@ -import Vue from 'vue'; +import { initSimpleApp } from '~/helpers/init_simple_app_helper'; import MlCandidate from '~/ml/experiment_tracking/components/ml_candidate.vue'; -const initShowCandidate = () => { - const element = document.querySelector('#js-show-ml-candidate'); - if (!element) { - return; - } - - const container = document.createElement('div'); - element.appendChild(container); - - const candidate = JSON.parse(element.dataset.candidate); - - // eslint-disable-next-line no-new - new Vue({ - el: container, - provide: { - candidate, - }, - render(h) { - return h(MlCandidate); - }, - }); -}; - -initShowCandidate(); +initSimpleApp('#js-show-ml-candidate', MlCandidate); diff --git a/app/assets/javascripts/pages/projects/ml/experiments/index/index.js b/app/assets/javascripts/pages/projects/ml/experiments/index/index.js new file mode 100644 index 00000000000..e9ffd4b528b --- /dev/null +++ b/app/assets/javascripts/pages/projects/ml/experiments/index/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import MlExperimentsIndex from '~/ml/experiment_tracking/routes/experiments/index'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +const initIndexMlExperiments = () => { + const element = document.querySelector('#js-project-ml-experiments-index'); + if (!element) { + return undefined; + } + + const props = { + experiments: JSON.parse(element.dataset.experiments), + pageInfo: convertObjectPropsToCamelCase(JSON.parse(element.dataset.pageInfo)), + }; + + return new Vue({ + el: element, + render(h) { + return h(MlExperimentsIndex, { props }); + }, + }); +}; + +initIndexMlExperiments(); diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js index 6947b15dcbe..0e64d8c17db 100644 --- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js +++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js @@ -14,7 +14,7 @@ const initShowExperiment = () => { const candidates = JSON.parse(element.dataset.candidates); const metricNames = JSON.parse(element.dataset.metrics); const paramNames = JSON.parse(element.dataset.params); - const pagination = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pagination)); + const pageInfo = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pageInfo)); // eslint-disable-next-line no-new new Vue({ @@ -23,7 +23,7 @@ const initShowExperiment = () => { candidates, metricNames, paramNames, - pagination, + pageInfo, }, render(h) { return h(MlExperiment); diff --git a/app/assets/javascripts/pages/projects/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js index 2dabcfadfab..414636f0a74 100644 --- a/app/assets/javascripts/pages/projects/network/show/index.js +++ b/app/assets/javascripts/pages/projects/network/show/index.js @@ -1,7 +1,39 @@ import $ from 'jquery'; +import Vue from 'vue'; +import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; import ShortcutsNetwork from '~/behaviors/shortcuts/shortcuts_network'; +import RefSelector from '~/ref/components/ref_selector.vue'; import Network from '../network'; +const initRefSwitcher = () => { + const refSwitcherEl = document.getElementById('js-graph-ref-switcher'); + const NETWORK_PATH_REGEX = /^(.*?)\/-\/network/g; + + if (!refSwitcherEl) return false; + + const { projectId, ref, networkPath } = refSwitcherEl.dataset; + const networkRootPath = networkPath.match(NETWORK_PATH_REGEX)?.[0]; // gets the network path without the ref + + return new Vue({ + el: refSwitcherEl, + render(createElement) { + return createElement(RefSelector, { + props: { + projectId, + value: ref, + }, + on: { + input(selectedRef) { + visitUrl(joinPaths(networkRootPath, selectedRef)); + }, + }, + }); + }, + }); +}; + +initRefSwitcher(); + (() => { if (!$('.network-graph').length) return; diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 4c9eb830ff6..5773737c41b 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -9,7 +9,6 @@ import axios from '~/lib/utils/axios_utils'; import { serializeForm } from '~/lib/utils/forms'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import projectSelect from '~/project_select'; const BRANCH_REF_TYPE = 'heads'; const TAG_REF_TYPE = 'tags'; @@ -44,13 +43,6 @@ export default class Project { $(this).parents('.auto-devops-implicitly-enabled-banner').remove(); return e.preventDefault(); }); - - Project.projectSelectDropdown(); - } - - static projectSelectDropdown() { - projectSelect(); - $('.project-item-select').on('click', (e) => Project.changeProject($(e.currentTarget).val())); } static changeProject(url) { diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index 2fd372a45b8..79a4ed0f9c3 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -21,7 +21,6 @@ const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; initMembersApp(document.querySelector('.js-project-members-list-app'), { [MEMBER_TYPES.user]: { tableFields: SHARED_FIELDS.concat(['source', 'activity']), - tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, tableSortableFields: [ 'account', 'granted', @@ -41,10 +40,6 @@ initMembersApp(document.querySelector('.js-project-members-list-app'), { }, [MEMBER_TYPES.group]: { tableFields: SHARED_FIELDS.concat(['source', 'granted']), - tableAttrs: { - table: { 'data-qa-selector': 'groups_list' }, - tr: { 'data-qa-selector': 'group_row' }, - }, requestFormatter: groupLinkRequestFormatter, filteredSearchBar: { show: true, diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 895c7d0a18e..964c6ca9792 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -4,7 +4,6 @@ import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers'; import initVariableList from '~/ci/ci_variable_list'; import initDeployFreeze from '~/deploy_freeze'; import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle'; -import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deployments'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle'; import initSettingsPanels from '~/settings_panels'; @@ -44,7 +43,5 @@ initArtifactsSettings(); initProjectRunners(); initSharedRunnersToggle(); initInstallRunner(); -initRunnerAwsDeployments(); - initTokenAccess(); initCiSecureFiles(); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 5fa3288bbef..f2bc4796324 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -49,14 +49,10 @@ export default { infrastructureLabel: s__('ProjectSettings|Infrastructure'), infrastructureHelpText: s__('ProjectSettings|Configure your infrastructure.'), monitorLabel: s__('ProjectSettings|Monitor'), - packagesHelpText: s__( - 'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.', - ), packageRegistryHelpText: s__('ProjectSettings|Publish, store, and view packages in a project.'), packageRegistryForEveryoneHelpText: s__( 'ProjectSettings|Anyone can pull packages with a package manager API.', ), - packagesLabel: s__('ProjectSettings|Packages'), packageRegistryLabel: s__('ProjectSettings|Package registry'), packageRegistryForEveryoneLabel: s__( 'ProjectSettings|Allow anyone to pull from Package Registry', @@ -355,9 +351,6 @@ export default { this.visibilityLevel < this.currentSettings.visibilityLevel ); }, - packageRegistryAccessLevelEnabled() { - return this.glFeatures.packageRegistryAccessLevel; - }, packageRegistryEnabled() { return this.packageRegistryAccessLevel > featureAccessLevel.NOT_ENABLED; }, @@ -392,14 +385,12 @@ export default { featureAccessLevel.PROJECT_MEMBERS, this.buildsAccessLevel, ); - if (this.packageRegistryAccessLevelEnabled) { - if ( - this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE || - (this.packageRegistryAccessLevel > featureAccessLevel.EVERYONE && - oldValue === VISIBILITY_LEVEL_PUBLIC_INTEGER) - ) { - this.packageRegistryAccessLevel = featureAccessLevel.PROJECT_MEMBERS; - } + if ( + this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE || + (this.packageRegistryAccessLevel > featureAccessLevel.EVERYONE && + oldValue === VISIBILITY_LEVEL_PUBLIC_INTEGER) + ) { + this.packageRegistryAccessLevel = featureAccessLevel.PROJECT_MEMBERS; } this.wikiAccessLevel = Math.min(featureAccessLevel.PROJECT_MEMBERS, this.wikiAccessLevel); this.snippetsAccessLevel = Math.min( @@ -459,10 +450,7 @@ export default { this.repositoryAccessLevel = featureAccessLevel.EVERYONE; if (this.mergeRequestsAccessLevel > featureAccessLevel.NOT_ENABLED) this.mergeRequestsAccessLevel = featureAccessLevel.EVERYONE; - if ( - this.packageRegistryAccessLevelEnabled && - this.packageRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS - ) { + if (this.packageRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS) { this.packageRegistryAccessLevel = PACKAGE_REGISTRY_ACCESS_LEVEL_DEFAULT_BY_PROJECT_VISIBILITY[value]; } @@ -488,19 +476,17 @@ export default { this.containerRegistryAccessLevel = featureAccessLevel.EVERYONE; this.highlightChanges(); - } else if (this.packageRegistryAccessLevelEnabled) { - if ( - value === VISIBILITY_LEVEL_PUBLIC_INTEGER && - this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE - ) { - // eslint-disable-next-line prefer-destructuring - this.packageRegistryAccessLevel = FEATURE_ACCESS_LEVEL_ANONYMOUS[0]; - } else if ( - value === VISIBILITY_LEVEL_INTERNAL_INTEGER && - this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0] - ) { - this.packageRegistryAccessLevel = featureAccessLevel.EVERYONE; - } + } else if ( + value === VISIBILITY_LEVEL_PUBLIC_INTEGER && + this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE + ) { + // eslint-disable-next-line prefer-destructuring + this.packageRegistryAccessLevel = FEATURE_ACCESS_LEVEL_ANONYMOUS[0]; + } else if ( + value === VISIBILITY_LEVEL_INTERNAL_INTEGER && + this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0] + ) { + this.packageRegistryAccessLevel = featureAccessLevel.EVERYONE; } }, @@ -770,22 +756,6 @@ export default { </p> </project-setting-row> <project-setting-row - v-if="packagesAvailable && !packageRegistryAccessLevelEnabled" - ref="package-settings" - :help-path="packagesHelpPath" - :label="$options.i18n.packagesLabel" - :help-text="$options.i18n.packagesHelpText" - > - <gl-toggle - v-model="packagesEnabled" - class="gl-my-2" - :disabled="!repositoryEnabled" - :label="$options.i18n.packagesLabel" - label-position="hidden" - name="project[packages_enabled]" - /> - </project-setting-row> - <project-setting-row ref="pipeline-settings" :label="$options.i18n.ciCdLabel" :help-text="s__('ProjectSettings|Build, test, and deploy your changes.')" @@ -889,7 +859,7 @@ export default { /> </project-setting-row> <project-setting-row - v-if="packageRegistryAccessLevelEnabled && packagesAvailable" + v-if="packagesAvailable" :help-path="packagesHelpPath" :label="$options.i18n.packageRegistryLabel" :help-text="$options.i18n.packageRegistryHelpText" diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 1de36f4a0fb..33d4090011f 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -6,6 +6,7 @@ import initClustersDeprecationAlert from '~/projects/clusters_deprecation_alert' import leaveByUrl from '~/namespaces/leave_by_url'; import initVueNotificationsDropdown from '~/notifications'; import Star from '~/projects/star'; +import initTerraformNotification from '~/projects/terraform_notification'; import { initUploadFileTrigger } from '~/projects/upload_file'; import initReadMore from '~/read_more'; @@ -44,6 +45,7 @@ initUploadFileTrigger(); initInviteMembersModal(); initInviteMembersTrigger(); initClustersDeprecationAlert(); +initTerraformNotification(); initReadMore(); new Star(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/search/show/refresh_counts.js b/app/assets/javascripts/pages/search/show/refresh_counts.js deleted file mode 100644 index f3f6312cb7c..00000000000 --- a/app/assets/javascripts/pages/search/show/refresh_counts.js +++ /dev/null @@ -1,24 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -function showCount(el, count) { - el.textContent = count; - el.classList.remove('hidden'); -} - -function refreshCount(el) { - const { url } = el.dataset; - - return axios - .get(url) - .then(({ data }) => showCount(el, data.count)) - .catch((e) => { - // eslint-disable-next-line no-console - console.error(`Failed to fetch search count from '${url}'.`, e); - }); -} - -export default function refreshCounts() { - const elements = Array.from(document.querySelectorAll('.js-search-count')); - - return Promise.all(elements.map(refreshCount)); -} diff --git a/app/assets/javascripts/pages/shared/mount_runner_aws_deployments.js b/app/assets/javascripts/pages/shared/mount_runner_aws_deployments.js deleted file mode 100644 index f3807a33a2b..00000000000 --- a/app/assets/javascripts/pages/shared/mount_runner_aws_deployments.js +++ /dev/null @@ -1,17 +0,0 @@ -import Vue from 'vue'; -import RunnerAwsDeployments from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue'; - -export function initRunnerAwsDeployments(componentId = 'js-runner-aws-deployments') { - const el = document.getElementById(componentId); - - if (!el) { - return null; - } - - return new Vue({ - el, - render(createElement) { - return createElement(RunnerAwsDeployments); - }, - }); -} diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 8e2f542aec0..0d2bbfbbc43 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -119,6 +119,12 @@ export default { isContentEditorActive: false, switchEditingControlDisabled: false, isFormDirty: getIsFormDirty(this.pageInfo), + formFieldProps: { + placeholder: this.$options.i18n.content.placeholder, + 'aria-label': this.$options.i18n.content.label, + id: 'wiki_content', + name: 'wiki[content]', + }, }; }, computed: { @@ -338,16 +344,13 @@ export default { <gl-form-group> <markdown-editor v-model="content" + :form-field-props="formFieldProps" :render-markdown-path="pageInfo.markdownPreviewPath" :markdown-docs-path="pageInfo.markdownHelpPath" :uploads-path="pageInfo.uploadsPath" :enable-content-editor="isMarkdownFormat" :enable-preview="isMarkdownFormat" :autofocus="pageInfo.persisted" - :form-field-placeholder="$options.i18n.content.placeholder" - :form-field-aria-label="$options.i18n.content.label" - form-field-id="wiki_content" - form-field-name="wiki[content]" @contentEditor="notifyContentEditorActive" @markdownField="notifyContentEditorInactive" @keydown.ctrl.enter="submitFormShortcut" diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js new file mode 100644 index 00000000000..f1b4e00c810 --- /dev/null +++ b/app/assets/javascripts/pages/users/show/index.js @@ -0,0 +1,16 @@ +import { s__ } from '~/locale'; +import { createAlert } from '~/flash'; + +if (window.gon.features?.profileTabsVue) { + import('~/profile') + .then(({ initProfileTabs }) => { + initProfileTabs(); + }) + .catch(() => { + createAlert({ + message: s__( + 'UserProfile|An error occurred loading the profile. Please refresh the page to try again.', + ), + }); + }); +} diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index ea8005e8dfb..69d60a7caf9 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -158,7 +158,7 @@ export default { v-model="sortOrder" :toggle-text="$options.sortOrderOptions[sortOrder].text" :items="Object.values($options.sortOrderOptions)" - right + placement="right" data-testid="performance-bar-sort-order" /> </div> diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index 139da5dabbd..e37f63d4053 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -20,6 +20,8 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-web-hook-disabled-callout', '.js-merge-request-settings-callout', '.js-ultimate-feature-removal-banner', + '.js-geo-enable-hashed-storage-callout', + '.js-geo-migrate-hashed-storage-callout', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js index 85ca52f633e..e650a48bc2a 100644 --- a/app/assets/javascripts/pipelines/components/graph/constants.js +++ b/app/assets/javascripts/pipelines/components/graph/constants.js @@ -10,6 +10,8 @@ export const ONE_COL_WIDTH = 180; export const STAGE_VIEW = 'stage'; export const LAYER_VIEW = 'layer'; + +export const SKIP_RETRY_MODAL_KEY = 'skip_retry_modal'; export const VIEW_TYPE_KEY = 'pipeline_graph_view_type'; export const SINGLE_JOB = 'single_job'; @@ -20,3 +22,5 @@ export const BRIDGE_KIND = 'BRIDGE'; export const ACTION_FAILURE = 'action_failure'; export const IID_FAILURE = 'missing_iid'; + +export const RETRY_ACTION_TITLE = 'Retry'; diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 1a05710a13e..49df71beeec 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -2,7 +2,10 @@ import { reportToSentry } from '../../utils'; import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue'; import LinksLayer from '../graph_shared/links_layer.vue'; -import { generateColumnsFromLayersListMemoized } from '../parsing_utils'; +import { + generateColumnsFromLayersListMemoized, + keepLatestDownstreamPipelines, +} from '../parsing_utils'; import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from './constants'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import StageColumnComponent from './stage_column_component.vue'; @@ -44,6 +47,11 @@ export default { required: false, default: () => ({}), }, + skipRetryModal: { + type: Boolean, + required: false, + default: false, + }, type: { type: String, required: false, @@ -76,7 +84,9 @@ export default { return `${this.$options.BASE_CONTAINER_ID}-${this.pipeline.id}`; }, downstreamPipelines() { - return this.hasDownstreamPipelines ? this.pipeline.downstream : []; + return this.hasDownstreamPipelines + ? keepLatestDownstreamPipelines(this.pipeline.downstream) + : []; }, layout() { return this.isStageView @@ -181,9 +191,11 @@ export default { :linked-pipelines="upstreamPipelines" :column-title="__('Upstream')" :show-links="showJobLinks" + :skip-retry-modal="skipRetryModal" :type="$options.pipelineTypeConstants.UPSTREAM" :view-type="viewType" @error="onError" + @setSkipRetryModal="$emit('setSkipRetryModal')" /> </template> <template #main> @@ -210,11 +222,13 @@ export default { :highlighted-jobs="highlightedJobs" :is-stage-view="isStageView" :job-hovered="hoveredJobName" + :skip-retry-modal="skipRetryModal" :source-job-hovered="hoveredSourceJobName" :pipeline-expanded="pipelineExpanded" :pipeline-id="pipeline.id" :user-permissions="pipeline.userPermissions" @refreshPipelineGraph="$emit('refreshPipelineGraph')" + @setSkipRetryModal="$emit('setSkipRetryModal')" @jobHover="setJob" @updateMeasurements="getMeasurements" /> @@ -228,12 +242,15 @@ export default { :config-paths="configPaths" :linked-pipelines="downstreamPipelines" :column-title="__('Downstream')" + :skip-retry-modal="skipRetryModal" :show-links="showJobLinks" :type="$options.pipelineTypeConstants.DOWNSTREAM" :view-type="viewType" + data-testid="downstream-pipelines" @downstreamHovered="setSourceJob" @pipelineExpandToggle="togglePipelineExpanded" @refreshPipelineGraph="$emit('refreshPipelineGraph')" + @setSkipRetryModal="$emit('setSkipRetryModal')" @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 4d7596e6e16..8f76d7535f1 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,14 @@ 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 { ACTION_FAILURE, IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants'; +import { + ACTION_FAILURE, + IID_FAILURE, + LAYER_VIEW, + SKIP_RETRY_MODAL_KEY, + STAGE_VIEW, + VIEW_TYPE_KEY, +} from './constants'; import PipelineGraph from './graph_component.vue'; import GraphViewSelector from './graph_view_selector.vue'; import { @@ -53,6 +60,7 @@ export default { currentViewType: STAGE_VIEW, canRefetchHeaderPipeline: false, pipeline: null, + skipRetryModal: false, showAlert: false, showLinks: false, }; @@ -206,8 +214,8 @@ export default { if (!this.pipelineIid) { this.reportFailure({ type: IID_FAILURE, skipSentry: true }); } - toggleQueryPollingByVisibility(this.$apollo.queries.pipeline); + this.skipRetryModal = Boolean(JSON.parse(localStorage.getItem(SKIP_RETRY_MODAL_KEY))); }, errorCaptured(err, _vm, info) { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); @@ -259,6 +267,9 @@ export default { updateShowLinksState(val) { this.showLinks = val; }, + setSkipRetryModal() { + this.skipRetryModal = true; + }, updateViewType(type) { this.currentViewType = type; }, @@ -293,10 +304,12 @@ export default { :config-paths="configPaths" :pipeline="pipeline" :computed-pipeline-info="getPipelineInfo()" + :skip-retry-modal="skipRetryModal" :show-links="showLinks" :view-type="graphViewType" @error="reportFailure" @refreshPipelineGraph="refreshPipelineGraph" + @setSkipRetryModal="setSkipRetryModal" /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 4f2be27486c..992e3d2f552 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -1,13 +1,14 @@ <script> -import { GlBadge, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { GlBadge, GlForm, GlFormCheckbox, GlLink, GlModal, GlTooltipDirective } from '@gitlab/ui'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; -import { sprintf, __ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; 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 { BRIDGE_KIND, SINGLE_JOB } from './constants'; +import { BRIDGE_KIND, RETRY_ACTION_TITLE, SINGLE_JOB, SKIP_RETRY_MODAL_KEY } from './constants'; /** * Renders the badge for the pipeline graph and the job's dropdown. @@ -35,17 +36,32 @@ import { BRIDGE_KIND, SINGLE_JOB } from './constants'; */ export default { + confirmationModalDocLink: helpPagePath('/ci/pipelines/downstream_pipelines'), i18n: { bridgeBadgeText: __('Trigger job'), unauthorizedTooltip: __('You are not authorized to run this manual job'), + confirmationModal: { + title: s__('PipelineGraph|Are you sure you want to retry %{jobName}?'), + description: s__( + 'PipelineGraph|Retrying a trigger job will create a new downstream pipeline.', + ), + linkText: s__('PipelineGraph|What is a downstream pipeline?'), + footer: __("Don't show this again"), + actionPrimary: { text: __('Retry') }, + actionCancel: { text: __('Cancel') }, + }, + runAgainTooltipText: __('Run again'), }, hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', components: { ActionComponent, CiIcon, - JobNameComponent, GlBadge, + GlForm, + GlFormCheckbox, GlLink, + GlModal, + JobNameComponent, }, directives: { GlTooltip: GlTooltipDirective, @@ -86,6 +102,11 @@ export default { required: false, default: -1, }, + skipRetryModal: { + type: Boolean, + required: false, + default: false, + }, sourceJobHovered: { type: String, required: false, @@ -102,6 +123,13 @@ export default { default: SINGLE_JOB, }, }, + data() { + return { + currentSkipModalValue: this.skipRetryModal, + showConfirmationModal: false, + shouldTriggerActionClick: false, + }; + }, computed: { boundary() { return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; @@ -115,6 +143,12 @@ export default { hasDetails() { return this.status.hasDetails; }, + hasRetryAction() { + return Boolean(this.job?.status?.action?.title === RETRY_ACTION_TITLE); + }, + isRetryableBridge() { + return this.isBridge && this.hasRetryAction; + }, isSingleItem() { return this.type === SINGLE_JOB; }, @@ -127,6 +161,11 @@ export default { nameComponent() { return this.hasDetails ? 'gl-link' : 'div'; }, + retryTriggerJobWarningText() { + return sprintf(this.$options.i18n.confirmationModal.title, { + jobName: this.job.name, + }); + }, showStageName() { return Boolean(this.stageName); }, @@ -205,11 +244,34 @@ export default { }, ]; }, + withConfirmationModal() { + return this.isRetryableBridge && !this.skipRetryModal; + }, + jobActionTooltipText() { + const { group } = this.status; + const { title, icon } = this.status.action; + + return icon === 'retry' && group === 'success' + ? this.$options.i18n.runAgainTooltipText + : title; + }, + }, + watch: { + skipRetryModal(val) { + this.currentSkipModalValue = val; + this.shouldTriggerActionClick = false; + }, }, errorCaptured(err, _vm, info) { reportToSentry('job_item', `error: ${err}, info: ${info}`); }, methods: { + handleConfirmationModalPreferences() { + if (this.currentSkipModalValue) { + this.$emit('setSkipRetryModal'); + localStorage.setItem(SKIP_RETRY_MODAL_KEY, String(this.currentSkipModalValue)); + } + }, hideTooltips() { this.$root.$emit(BV_HIDE_TOOLTIP); }, @@ -227,6 +289,15 @@ export default { pipelineActionRequestComplete() { this.$emit('pipelineActionRequestComplete'); }, + executePendingAction() { + this.shouldTriggerActionClick = true; + }, + showActionConfirmationModal() { + this.showConfirmationModal = true; + }, + toggleSkipRetryModalCheckbox() { + this.currentSkipModalValue = !this.currentSkipModalValue; + }, }, }; </script> @@ -272,12 +343,16 @@ export default { <action-component v-if="hasAction" - :tooltip-text="status.action.title" + :tooltip-text="jobActionTooltipText" :link="status.action.path" :action-icon="status.action.icon" class="gl-mr-1" + :should-trigger-click="shouldTriggerActionClick" + :with-confirmation-modal="withConfirmationModal" data-qa-selector="job_action_button" + @actionButtonClicked="handleConfirmationModalPreferences" @pipelineActionRequestComplete="pipelineActionRequestComplete" + @showActionConfirmationModal="showActionConfirmationModal" /> <action-component v-if="hasUnauthorizedManualAction" @@ -287,5 +362,28 @@ export default { :link="`unauthorized-${computedJobId}`" class="gl-mr-1" /> + <gl-modal + v-if="showConfirmationModal" + ref="modal" + v-model="showConfirmationModal" + modal-id="action-confirmation-modal" + :title="retryTriggerJobWarningText" + :action-cancel="$options.i18n.confirmationModal.actionCancel" + :action-primary="$options.i18n.confirmationModal.actionPrimary" + @primary="executePendingAction" + @close="handleConfirmationModalPreferences" + @hide="handleConfirmationModalPreferences" + > + <p class="gl-mb-1">{{ $options.i18n.confirmationModal.description }}</p> + <gl-link :href="$options.confirmationModalDocLink" target="_blank">{{ + $options.i18n.confirmationModal.linkText + }}</gl-link> + <div class="gl-mt-4 gl-display-flex"> + <gl-form> + <gl-form-checkbox class="gl-min-h-0" @input="toggleSkipRetryModalCheckbox" /> + </gl-form> + <p class="gl-m-0">{{ $options.i18n.confirmationModal.footer }}</p> + </div> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 225706265c3..9b4e5d471d6 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -7,13 +7,13 @@ import { GlTooltip, GlTooltipDirective, } from '@gitlab/ui'; +import { TYPENAME_CI_PIPELINE } from '~/graphql_shared/constants'; 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 { PIPELINE_GRAPHQL_TYPE } from '../../constants'; import { reportToSentry } from '../../utils'; import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from './constants'; @@ -118,7 +118,7 @@ export default { return this.isUpstream ? 'gl-flex-direction-row-reverse' : 'gl-flex-direction-row'; }, graphqlPipelineId() { - return convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, this.pipeline.id); + return convertToGraphQLId(TYPENAME_CI_PIPELINE, this.pipeline.id); }, hasUpdatePipelinePermissions() { return Boolean(this.pipeline?.userPermissions?.updatePipeline); 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 b06c2f15042..02e426064c9 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -36,6 +36,11 @@ export default { type: Boolean, required: true, }, + skipRetryModal: { + type: Boolean, + required: false, + default: false, + }, type: { type: String, required: true, @@ -229,8 +234,10 @@ export default { :pipeline="currentPipeline" :computed-pipeline-info="getPipelineLayers(pipeline.id)" :show-links="showLinks" + :skip-retry-modal="skipRetryModal" :is-linked-pipeline="true" :view-type="graphViewType" + @setSkipRetryModal="$emit('setSkipRetryModal')" /> </div> </li> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index 4aec28295bd..ffd0fec2ca8 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -53,6 +53,11 @@ export default { required: false, default: () => ({}), }, + skipRetryModal: { + type: Boolean, + required: false, + default: false, + }, sourceJobHovered: { type: String, required: false, @@ -164,6 +169,7 @@ export default { v-if="singleJobExists(group)" :job="group.jobs[0]" :job-hovered="jobHovered" + :skip-retry-modal="skipRetryModal" :source-job-hovered="sourceJobHovered" :pipeline-expanded="pipelineExpanded" :pipeline-id="pipelineId" @@ -174,6 +180,7 @@ export default { 'gl-transition-duration-slow gl-transition-timing-function-ease', ]" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" + @setSkipRetryModal="$emit('setSkipRetryModal')" /> <div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }"> <job-group-dropdown diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue index 387b01aee7e..7020bfc1e65 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue @@ -39,6 +39,16 @@ export default { type: String, required: true, }, + withConfirmationModal: { + type: Boolean, + required: false, + default: false, + }, + shouldTriggerClick: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -52,6 +62,14 @@ export default { return `${actionIconDash} js-icon-${actionIconDash}`; }, }, + watch: { + shouldTriggerClick(flag) { + if (flag && this.withConfirmationModal) { + this.executeAction(); + this.$emit('actionButtonClicked'); + } + }, + }, errorCaptured(err, _vm, info) { reportToSentry('action_component', `error: ${err}, info: ${info}`); }, @@ -63,6 +81,13 @@ export default { * */ onClickAction() { + if (this.withConfirmationModal) { + this.$emit('showActionConfirmationModal'); + } else { + this.executeAction(); + } + }, + executeAction() { this.$root.$emit(BV_HIDE_TOOLTIP, `js-ci-action-${this.link}`); this.isDisabled = true; this.isLoading = true; @@ -91,6 +116,7 @@ export default { <template> <gl-button :id="`js-ci-action-${link}`" + ref="button" :class="cssClass" :disabled="isDisabled" class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center" diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js index cae4e11c13f..e158f8809b5 100644 --- a/app/assets/javascripts/pipelines/components/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/parsing_utils.js @@ -170,3 +170,13 @@ export const generateColumnsFromLayersListBare = ({ stages, stagesLookup }, pipe }; export const generateColumnsFromLayersListMemoized = memoize(generateColumnsFromLayersListBare); + +export const keepLatestDownstreamPipelines = (downstreamPipelines = []) => { + return downstreamPipelines.filter((pipeline) => { + if (pipeline.source_job) { + return !pipeline?.source_job?.retried || false; + } + + return !pipeline?.sourceJob?.retried || false; + }); +}; diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue index 51b46f25048..66bf5068149 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue @@ -2,7 +2,7 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; -import { sprintf } from '~/locale'; +import { __, sprintf } from '~/locale'; import { reportToSentry } from '../../utils'; import ActionComponent from '../jobs_shared/action_component.vue'; import JobNameComponent from '../jobs_shared/job_name_component.vue'; @@ -33,6 +33,9 @@ import JobNameComponent from '../jobs_shared/job_name_component.vue'; */ export default { + i18n: { + runAgainTooltipText: __('Run again'), + }, hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', components: { ActionComponent, @@ -129,6 +132,14 @@ export default { ? `${this.$options.hoverClass} ${this.cssClassJobName}` : this.cssClassJobName; }, + jobActionTooltipText() { + const { group } = this.status; + const { title, icon } = this.status.action; + + return icon === 'retry' && group === 'success' + ? this.$options.i18n.runAgainTooltipText + : title; + }, }, errorCaptured(err, _vm, info) { reportToSentry('pipelines_job_item', `pipelines_job_item error: ${err}, info: ${info}`); @@ -177,7 +188,7 @@ export default { <action-component v-if="hasAction" - :tooltip-text="status.action.title" + :tooltip-text="jobActionTooltipText" :link="status.action.path" :action-icon="status.action.icon" data-qa-selector="action_button" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index c498f12d5c7..4111823e0bb 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -311,7 +311,7 @@ export default { this.resetRequestData(); } - this.updateContent(this.requestData); + this.updateContent({ ...this.requestData, page: '1' }); }, changeVisibilityPipelineID(val) { this.selectedPipelineKeyOption = PipelineKeyOptions.find((e) => val === e.value); 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 ed32d643c0e..365572f194b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -2,6 +2,7 @@ import { GlTableLite, GlTooltipDirective } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; +import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import eventHub from '../../event_hub'; import { TRACKING_CATEGORIES } from '../../constants'; @@ -115,6 +116,10 @@ export default { eventHub.$off('openConfirmationModal', this.setModalData); }, methods: { + getDownstreamPipelines(pipeline) { + const downstream = pipeline.triggered; + return keepLatestDownstreamPipelines(downstream); + }, setModalData(data) { this.pipelineId = data.pipeline.id; this.pipeline = data.pipeline; @@ -171,7 +176,7 @@ export default { <template #cell(stages)="{ item }"> <pipeline-mini-graph - :downstream-pipelines="item.triggered" + :downstream-pipelines="getDownstreamPipelines(item)" :pipeline-path="item.path" :stages="item.details.stages" :update-dropdown="updateGraphDropdown" diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 2f37f90e625..820501089ed 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -9,7 +9,6 @@ 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', diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index f00378733fc..ba51347ad69 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -1,6 +1,7 @@ import VueRouter from 'vue-router'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; +import { pipelineTabName } from './constants'; import { createPipelineHeaderApp } from './pipeline_details_header'; import { apolloProvider } from './pipeline_shared_client'; @@ -38,6 +39,12 @@ export default async function initPipelineDetailsBundle() { routes, }); + // We handle the shortcut `pipelines/latest` by forwarding the user to the pipeline graph + // tab and changing the route to the correct `pipelines/:id` + if (window.location.pathname.endsWith('latest')) { + router.replace({ name: pipelineTabName }); + } + try { const appOptions = createAppOptions(SELECTORS.PIPELINE_TABS, apolloProvider, router); createPipelineTabs(appOptions); diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js index d0ee6871a48..6360ccc41bc 100644 --- a/app/assets/javascripts/pipelines/pipeline_tabs.js +++ b/app/assets/javascripts/pipelines/pipeline_tabs.js @@ -34,6 +34,7 @@ export const createAppOptions = (selector, apolloProvider, router) => { totalJobCount, licenseManagementApiUrl, licenseManagementSettingsPath, + licenseScanCount, licensesApiPath, canManageLicenses, summaryEndpoint, @@ -87,6 +88,7 @@ export const createAppOptions = (selector, apolloProvider, router) => { totalJobCount, licenseManagementApiUrl, licenseManagementSettingsPath, + licenseScanCount, licensesApiPath, canManageLicenses: parseBoolean(canManageLicenses), summaryEndpoint, diff --git a/app/assets/javascripts/profile/components/activity_tab.vue b/app/assets/javascripts/profile/components/activity_tab.vue new file mode 100644 index 00000000000..aae5c489e88 --- /dev/null +++ b/app/assets/javascripts/profile/components/activity_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Activity'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/contributed_projects_tab.vue b/app/assets/javascripts/profile/components/contributed_projects_tab.vue new file mode 100644 index 00000000000..e490643e57a --- /dev/null +++ b/app/assets/javascripts/profile/components/contributed_projects_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Contributed projects'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/followers_tab.vue b/app/assets/javascripts/profile/components/followers_tab.vue new file mode 100644 index 00000000000..47651c33eb8 --- /dev/null +++ b/app/assets/javascripts/profile/components/followers_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Followers'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/following_tab.vue b/app/assets/javascripts/profile/components/following_tab.vue new file mode 100644 index 00000000000..6d9631c5e89 --- /dev/null +++ b/app/assets/javascripts/profile/components/following_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Following'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/groups_tab.vue b/app/assets/javascripts/profile/components/groups_tab.vue new file mode 100644 index 00000000000..6c4847872a7 --- /dev/null +++ b/app/assets/javascripts/profile/components/groups_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Groups'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/overview_tab.vue b/app/assets/javascripts/profile/components/overview_tab.vue new file mode 100644 index 00000000000..e884c2d7083 --- /dev/null +++ b/app/assets/javascripts/profile/components/overview_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Overview'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/personal_projects_tab.vue b/app/assets/javascripts/profile/components/personal_projects_tab.vue new file mode 100644 index 00000000000..285f01930e7 --- /dev/null +++ b/app/assets/javascripts/profile/components/personal_projects_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Personal projects'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue new file mode 100644 index 00000000000..2425d56c52a --- /dev/null +++ b/app/assets/javascripts/profile/components/profile_tabs.vue @@ -0,0 +1,72 @@ +<script> +import { GlTabs } from '@gitlab/ui'; + +import OverviewTab from './overview_tab.vue'; +import ActivityTab from './activity_tab.vue'; +import GroupsTab from './groups_tab.vue'; +import ContributedProjectsTab from './contributed_projects_tab.vue'; +import PersonalProjectsTab from './personal_projects_tab.vue'; +import StarredProjectsTab from './starred_projects_tab.vue'; +import SnippetsTab from './snippets_tab.vue'; +import FollowersTab from './followers_tab.vue'; +import FollowingTab from './following_tab.vue'; + +export default { + components: { + GlTabs, + OverviewTab, + ActivityTab, + GroupsTab, + ContributedProjectsTab, + PersonalProjectsTab, + StarredProjectsTab, + SnippetsTab, + FollowersTab, + FollowingTab, + }, + tabs: [ + { + key: 'overview', + component: OverviewTab, + }, + { + key: 'activity', + component: ActivityTab, + }, + { + key: 'groups', + component: GroupsTab, + }, + { + key: 'contributedProjects', + component: ContributedProjectsTab, + }, + { + key: 'personalProjects', + component: PersonalProjectsTab, + }, + { + key: 'starredProjects', + component: StarredProjectsTab, + }, + { + key: 'snippets', + component: SnippetsTab, + }, + { + key: 'followers', + component: FollowersTab, + }, + { + key: 'following', + component: FollowingTab, + }, + ], +}; +</script> + +<template> + <gl-tabs> + <component :is="component" v-for="{ key, component } in $options.tabs" :key="key" /> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/profile/components/snippets_tab.vue b/app/assets/javascripts/profile/components/snippets_tab.vue new file mode 100644 index 00000000000..d64c5b900a5 --- /dev/null +++ b/app/assets/javascripts/profile/components/snippets_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Snippets'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/starred_projects_tab.vue b/app/assets/javascripts/profile/components/starred_projects_tab.vue new file mode 100644 index 00000000000..b9ef1e6e713 --- /dev/null +++ b/app/assets/javascripts/profile/components/starred_projects_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Starred projects'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js new file mode 100644 index 00000000000..5378ed3d743 --- /dev/null +++ b/app/assets/javascripts/profile/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; + +import ProfileTabs from './components/profile_tabs.vue'; + +export const initProfileTabs = () => { + const el = document.getElementById('js-profile-tabs'); + + if (!el) return false; + + return new Vue({ + el, + render(createElement) { + return createElement(ProfileTabs); + }, + }); +}; diff --git a/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue b/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue index 74dd2d5628a..a8a25dc2ec6 100644 --- a/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue +++ b/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue @@ -110,8 +110,8 @@ export default { </td> <td class="line_content parallel left-side old"> <span> - <span>{{ ' ' }}</span> - <span class="k">print</span><span class="p">(</span><span class="n">i</span> + <span>{{ ' ' }}</span + ><span class="k">print</span><span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span></span > </td> @@ -120,8 +120,8 @@ export default { </td> <td class="line_content parallel right-side new"> <span> - <span>{{ ' ' }}</span> - <span class="k">print</span><span class="p">(</span><span class="n">i</span> + <span>{{ ' ' }}</span + ><span class="k">print</span><span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span></span > </td> @@ -162,8 +162,8 @@ export default { </td> <td class="line_content parallel left-side old"> <span> - <span>{{ ' ' }}</span> - <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span + <span>{{ ' ' }}</span + ><span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span ><span class="bp">self</span><span class="p">,</span> <span class="n">x</span ><span class="p">):</span></span > @@ -173,8 +173,8 @@ export default { </td> <td class="line_content parallel right-side new"> <span> - <span>{{ ' ' }}</span> - <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span + <span>{{ ' ' }}</span + ><span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span ><span class="bp">self</span><span class="p">,</span> <span class="n">x</span ><span class="p">):</span></span > @@ -186,8 +186,8 @@ export default { </td> <td class="line_content parallel left-side old"> <span> - <span>{{ ' ' }}</span> - <span class="bp">self</span><span class="p">.</span><span class="n">val</span> + <span>{{ ' ' }}</span + ><span class="bp">self</span><span class="p">.</span><span class="n">val</span> <span class="o">=</span> <span class="n">x</span></span > </td> @@ -196,8 +196,8 @@ export default { </td> <td class="line_content parallel right-side new"> <span> - <span>{{ ' ' }}</span> - <span class="bp">self</span><span class="p">.</span><span class="n">val</span> + <span>{{ ' ' }}</span + ><span class="bp">self</span><span class="p">.</span><span class="n">val</span> <span class="o">=</span> <span class="n">x</span></span > </td> @@ -208,8 +208,8 @@ export default { </td> <td class="line_content parallel left-side old"> <span> - <span>{{ ' ' }}</span> - <span class="bp">self</span><span class="p">.</span><span class="nb">next</span> + <span>{{ ' ' }}</span + ><span class="bp">self</span><span class="p">.</span><span class="nb">next</span> <span class="o">=</span> <span class="bp">None</span></span > </td> @@ -218,8 +218,8 @@ export default { </td> <td class="line_content parallel right-side new"> <span> - <span>{{ ' ' }}</span> - <span class="bp">self</span><span class="p">.</span><span class="nb">next</span> + <span>{{ ' ' }}</span + ><span class="bp">self</span><span class="p">.</span><span class="nb">next</span> <span class="o">=</span> <span class="bp">None</span></span > </td> diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js deleted file mode 100644 index 705234537a8..00000000000 --- a/app/assets/javascripts/project_select.js +++ /dev/null @@ -1,128 +0,0 @@ -/* eslint-disable func-names */ - -import $ from 'jquery'; -import { createAlert } from '~/flash'; -import Api from './api'; -import { loadCSSFile } from './lib/utils/css_utils'; -import { s__ } from './locale'; -import ProjectSelectComboButton from './project_select_combo_button'; - -const projectSelect = async () => { - await loadCSSFile(gon.select2_css_path); - - $('.ajax-project-select').each(function (i, select) { - let placeholder; - const simpleFilter = $(select).data('simpleFilter') || false; - const isInstantiated = $(select).data('select2'); - this.groupId = $(select).data('groupId'); - this.userId = $(select).data('userId'); - this.includeGroups = $(select).data('includeGroups'); - this.allProjects = $(select).data('allProjects') || false; - this.orderBy = $(select).data('orderBy') || 'id'; - this.withIssuesEnabled = $(select).data('withIssuesEnabled'); - this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); - this.withShared = - $(select).data('withShared') === undefined ? true : $(select).data('withShared'); - this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; - this.allowClear = $(select).data('allowClear') || false; - - placeholder = s__('ProjectSelect|Search for project'); - if (this.includeGroups) { - placeholder += s__('ProjectSelect| or group'); - } - - $(select).select2({ - placeholder, - minimumInputLength: 0, - query: (query) => { - let projectsCallback; - const finalCallback = function (projects) { - const data = { - results: projects, - }; - return query.callback(data); - }; - if (this.includeGroups) { - projectsCallback = function (projects) { - const groupsCallback = function (groups) { - const data = groups.concat(projects); - return finalCallback(data); - }; - return Api.groups(query.term, {}, groupsCallback); - }; - } else { - projectsCallback = finalCallback; - } - if (this.groupId) { - return Api.groupProjects( - this.groupId, - query.term, - { - with_issues_enabled: this.withIssuesEnabled, - with_merge_requests_enabled: this.withMergeRequestsEnabled, - with_shared: this.withShared, - include_subgroups: this.includeProjectsInSubgroups, - order_by: 'similarity', - simple: true, - }, - projectsCallback, - ).catch(() => { - createAlert({ - message: s__('ProjectSelect|Something went wrong while fetching projects'), - }); - }); - } else if (this.userId) { - return Api.userProjects( - this.userId, - query.term, - { - with_issues_enabled: this.withIssuesEnabled, - with_merge_requests_enabled: this.withMergeRequestsEnabled, - with_shared: this.withShared, - include_subgroups: this.includeProjectsInSubgroups, - }, - projectsCallback, - ); - } - return Api.projects( - query.term, - { - order_by: this.orderBy, - with_issues_enabled: this.withIssuesEnabled, - with_merge_requests_enabled: this.withMergeRequestsEnabled, - membership: !this.allProjects, - }, - projectsCallback, - ); - }, - id(project) { - if (simpleFilter) return project.id; - return JSON.stringify({ - name: project.name, - url: project.web_url, - }); - }, - text(project) { - return project.name_with_namespace || project.name; - }, - - initSelection(el, callback) { - return Api.project(el.val()).then(({ data }) => callback(data)); - }, - - allowClear: this.allowClear, - - dropdownCssClass: 'ajax-project-dropdown', - }); - if (isInstantiated || simpleFilter) return select; - return new ProjectSelectComboButton(select); - }); -}; - -export default () => { - if ($('.ajax-project-select').length) { - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(projectSelect) - .catch(() => {}); - } -}; diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js deleted file mode 100644 index ad80032c551..00000000000 --- a/app/assets/javascripts/project_select_combo_button.js +++ /dev/null @@ -1,122 +0,0 @@ -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'; - -export default class ProjectSelectComboButton { - constructor(select) { - this.projectSelectInput = $(select); - this.newItemBtn = $('.js-new-project-item-link'); - this.resourceType = this.newItemBtn.data('type'); - this.resourceLabel = this.newItemBtn.data('label'); - this.formattedText = this.deriveTextVariants(); - this.groupId = this.projectSelectInput.data('groupId'); - this.bindEvents(); - this.initLocalStorage(); - } - - bindEvents() { - this.projectSelectInput - .siblings('.new-project-item-select-button') - .on('click', (e) => this.openDropdown(e)); - - this.newItemBtn.on('click', (e) => { - if (!this.getProjectFromLocalStorage()) { - e.preventDefault(); - this.openDropdown(e); - } - }); - - this.projectSelectInput.on('change', () => this.selectProject()); - } - - initLocalStorage() { - const localStorageIsSafe = AccessorUtilities.canUseLocalStorage(); - - if (localStorageIsSafe) { - this.localStorageKey = [ - 'group', - this.groupId, - this.formattedText.localStorageItemType, - 'recent-project', - ].join('-'); - this.setBtnTextFromLocalStorage(); - } - } - - // eslint-disable-next-line class-methods-use-this - openDropdown(event) { - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(() => { - // eslint-disable-next-line promise/no-nesting - loadCSSFile(gon.select2_css_path) - .then(() => { - $(event.currentTarget).siblings('.project-item-select').select2('open'); - }) - .catch(() => {}); - }) - .catch(() => {}); - } - - selectProject() { - const selectedProjectData = JSON.parse(this.projectSelectInput.val()); - const projectUrl = `${selectedProjectData.url}/${this.projectSelectInput.data('relativePath')}`; - const projectName = selectedProjectData.name; - - const projectMeta = { - url: projectUrl, - name: projectName, - }; - - this.setNewItemBtnAttributes(projectMeta); - this.setProjectInLocalStorage(projectMeta); - } - - setBtnTextFromLocalStorage() { - const cachedProjectData = this.getProjectFromLocalStorage(); - - this.setNewItemBtnAttributes(cachedProjectData); - } - - setNewItemBtnAttributes(project) { - if (project) { - this.newItemBtn.attr('href', sanitizeUrl(project.url)); - this.newItemBtn.text( - sprintf(__('New %{type} in %{project}'), { - type: this.resourceLabel, - project: project.name, - }), - ); - } else { - this.newItemBtn.text( - sprintf(__('Select project to create %{type}'), { - type: this.formattedText.presetTextSuffix, - }), - ); - } - } - - getProjectFromLocalStorage() { - const projectString = localStorage.getItem(this.localStorageKey); - - return JSON.parse(projectString); - } - - setProjectInLocalStorage(projectMeta) { - const projectString = JSON.stringify(projectMeta); - - localStorage.setItem(this.localStorageKey, projectString); - } - - deriveTextVariants() { - // the trailing slice call depluralizes each of these strings (e.g. new-issues -> new-issue) - const localStorageItemType = `new-${this.resourceType.split('_').join('-').slice(0, -1)}`; - const presetTextSuffix = this.resourceType.split('_').join(' ').slice(0, -1); - - return { - localStorageItemType, // new-issue / new-merge-request - presetTextSuffix, // issue / merge request - }; - } -} diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue index a037e721677..0ed154c47dd 100644 --- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue @@ -1,12 +1,7 @@ <script> -import { - GlDropdown, - GlSearchBoxByType, - GlDropdownItem, - GlDropdownText, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import { debounce } from 'lodash'; import { I18N_NO_RESULTS_MESSAGE, I18N_BRANCH_HEADER, @@ -16,11 +11,7 @@ import { export default { name: 'BranchesDropdown', components: { - GlDropdown, - GlSearchBoxByType, - GlDropdownItem, - GlDropdownText, - GlLoadingIcon, + GlCollapsibleListbox, }, props: { value: { @@ -46,19 +37,17 @@ export default { }, computed: { ...mapGetters(['joinedBranches']), - ...mapState(['isFetching', 'branch', 'branches']), - filteredResults() { - const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); - return this.joinedBranches.filter((resultString) => - resultString.toLowerCase().includes(lowerCasedSearchTerm), - ); + ...mapState(['isFetching']), + listboxItems() { + return this.joinedBranches.map((value) => ({ value, text: value })); }, }, watch: { // Parent component can set the branch value (e.g. when the user selects a different project) // and we need to keep the search term in sync with the selected value value(val) { - this.searchTermChanged(val); + this.searchTerm = val; + this.fetchBranches(this.searchTerm); }, }, mounted() { @@ -67,50 +56,29 @@ export default { methods: { ...mapActions(['fetchBranches']), selectBranch(branch) { - this.$emit('selectBranch', branch); - this.searchTerm = branch; // enables isSelected to work as expected - }, - isSelected(selectedBranch) { - return selectedBranch === this.branch; + this.$emit('input', branch); }, + debouncedSearch: debounce(function debouncedSearch() { + this.fetchBranches(this.searchTerm); + }, 250), searchTermChanged(value) { - this.searchTerm = value; - this.fetchBranches(value); + this.searchTerm = value.trim(); + this.debouncedSearch(value); }, }, }; </script> <template> - <gl-dropdown :text="value" :header-text="$options.i18n.branchHeaderTitle"> - <gl-search-box-by-type - :value="searchTerm" - trim - autocomplete="off" - :debounce="250" - :placeholder="$options.i18n.branchSearchPlaceholder" - data-testid="dropdown-search-box" - @input="searchTermChanged" - /> - <gl-dropdown-item - v-for="branch in filteredResults" - v-show="!isFetching" - :key="branch" - :name="branch" - :is-checked="isSelected(branch)" - is-check-item - data-testid="dropdown-item" - @click="selectBranch(branch)" - > - {{ branch }} - </gl-dropdown-item> - <gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon"> - <gl-loading-icon size="sm" class="gl-mx-auto" /> - </gl-dropdown-text> - <gl-dropdown-text - v-if="!filteredResults.length && !isFetching" - data-testid="empty-result-message" - > - <span class="gl-text-gray-500">{{ $options.i18n.noResultsMessage }}</span> - </gl-dropdown-text> - </gl-dropdown> + <gl-collapsible-listbox + :header-text="$options.i18n.branchHeaderTitle" + :toggle-text="value" + :items="listboxItems" + searchable + :search-placeholder="$options.i18n.branchSearchPlaceholder" + :searching="isFetching" + :selected="value" + :no-results-text="$options.i18n.noResultsMessage" + @search="searchTermChanged" + @select="selectBranch" + /> </template> diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue index 1febe8ceaab..f78afef1c17 100644 --- a/app/assets/javascripts/projects/commit/components/form_modal.vue +++ b/app/assets/javascripts/projects/commit/components/form_modal.vue @@ -141,11 +141,7 @@ export default { :value="targetProjectId" /> - <projects-dropdown - class="gl-w-half" - :value="targetProjectName" - @selectProject="setSelectedProject" - /> + <projects-dropdown :value="targetProjectName" @selectProject="setSelectedProject" /> </gl-form-group> <gl-form-group @@ -155,12 +151,7 @@ export default { > <input id="start_branch" type="hidden" name="start_branch" :value="branch" /> - <branches-dropdown - class="gl-w-half" - :value="branch" - :blanked="isRevert" - @selectBranch="setBranch" - /> + <branches-dropdown :value="branch" :blanked="isRevert" @input="setBranch" /> </gl-form-group> <gl-form-checkbox diff --git a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue index 6288bcdaad0..d43f5b99e2c 100644 --- a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlSearchBoxByType, GlDropdownItem, GlDropdownText } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { mapGetters, mapState } from 'vuex'; import { I18N_NO_RESULTS_MESSAGE, @@ -10,10 +10,7 @@ import { export default { name: 'ProjectsDropdown', components: { - GlDropdown, - GlSearchBoxByType, - GlDropdownItem, - GlDropdownText, + GlCollapsibleListbox, }, props: { value: { @@ -41,17 +38,20 @@ export default { project.name.toLowerCase().includes(lowerCasedFilterTerm), ); }, + listboxItems() { + return this.filteredResults.map(({ id, name }) => ({ value: id, text: name })); + }, selectedProject() { return this.sortedProjects.find((project) => project.id === this.targetProjectId) || {}; }, }, methods: { - selectProject(project) { - this.$emit('selectProject', project.id); - this.filterTerm = project.name; // when we select a project, we want the dropdown to filter to the selected project - }, - isSelected(selectedProject) { - return selectedProject === this.selectedProject; + selectProject(value) { + this.$emit('selectProject', value); + + // when we select a project, we want the dropdown to filter to the selected project + const project = this.listboxItems.find((x) => x.value === value); + this.filterTerm = project?.text || ''; }, filterTermChanged(value) { this.filterTerm = value; @@ -60,28 +60,15 @@ export default { }; </script> <template> - <gl-dropdown :text="selectedProject.name" :header-text="$options.i18n.projectHeaderTitle"> - <gl-search-box-by-type - :value="filterTerm" - trim - autocomplete="off" - :placeholder="$options.i18n.projectSearchPlaceholder" - data-testid="dropdown-search-box" - @input="filterTermChanged" - /> - <gl-dropdown-item - v-for="project in filteredResults" - :key="project.name" - :name="project.name" - :is-checked="isSelected(project)" - is-check-item - data-testid="dropdown-item" - @click="selectProject(project)" - > - {{ project.name }} - </gl-dropdown-item> - <gl-dropdown-text v-if="!filteredResults.length" data-testid="empty-result-message"> - <span class="gl-text-gray-500">{{ $options.i18n.noResultsMessage }}</span> - </gl-dropdown-text> - </gl-dropdown> + <gl-collapsible-listbox + :header-text="$options.i18n.projectHeaderTitle" + :items="listboxItems" + searchable + :search-placeholder="$options.i18n.projectSearchPlaceholder" + :selected="selectedProject.id" + :toggle-text="selectedProject.name" + :no-results-text="$options.i18n.noResultsMessage" + @search="filterTermChanged" + @select="selectProject" + /> </template> diff --git a/app/assets/javascripts/projects/commit/store/getters.js b/app/assets/javascripts/projects/commit/store/getters.js index e0c36df8a75..b039ee3ba63 100644 --- a/app/assets/javascripts/projects/commit/store/getters.js +++ b/app/assets/javascripts/projects/commit/store/getters.js @@ -1,7 +1,7 @@ -import { uniq } from 'lodash'; +import { uniq, uniqBy } from 'lodash'; export const joinedBranches = (state) => { return uniq(state.branches).sort(); }; -export const sortedProjects = (state) => uniq(state.projects).sort(); +export const sortedProjects = (state) => uniqBy(state.projects, 'id').sort(); 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 0256eec6d56..dafc4bc5abf 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 @@ -6,6 +6,7 @@ import { getQueryHeaders, toggleQueryPollingByVisibility, } from '~/pipelines/components/graph/utils'; +import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import { formatStages } from '../utils'; import getLinkedPipelinesQuery from '../graphql/queries/get_linked_pipelines.query.graphql'; @@ -91,7 +92,8 @@ export default { }, computed: { downstreamPipelines() { - return this.pipeline?.downstream?.nodes; + const downstream = this.pipeline?.downstream?.nodes; + return keepLatestDownstreamPipelines(downstream); }, pipelinePath() { return this.pipeline?.path ?? ''; diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql index c6a0d48626a..9257cc7de7b 100644 --- a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql +++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql @@ -18,6 +18,10 @@ query getLinkedPipelines($fullPath: ID!, $iid: ID!) { icon label } + sourceJob { + id + retried + } } } upstream { diff --git a/app/assets/javascripts/projects/merge_requests/index.js b/app/assets/javascripts/projects/merge_requests/index.js deleted file mode 100644 index 25a70121d68..00000000000 --- a/app/assets/javascripts/projects/merge_requests/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import Vue from 'vue'; -import ReportAbuseDropdownItem from './components/report_abuse_dropdown_item.vue'; - -export const initReportAbuse = () => { - const el = document.getElementById('js-report-abuse-dropdown-item'); - - if (!el) return false; - - const { reportAbusePath, reportedUserId, reportedFromUrl } = el.dataset; - - return new Vue({ - el, - provide: { reportAbusePath, reportedUserId, reportedFromUrl }, - render(createElement) { - return createElement(ReportAbuseDropdownItem); - }, - }); -}; diff --git a/app/assets/javascripts/projects/project_name_rules.js b/app/assets/javascripts/projects/project_name_rules.js index eeef1fb5afc..4f62aa29ce4 100644 --- a/app/assets/javascripts/projects/project_name_rules.js +++ b/app/assets/javascripts/projects/project_name_rules.js @@ -1,28 +1,29 @@ import { __ } from '~/locale'; -const rulesReg = [ - { - reg: /^[a-zA-Z0-9\u{00A9}-\u{1f9ff}_]/u, - msg: __("Name must start with a letter, digit, emoji, or '_'"), - }, - { - reg: /^[a-zA-Z0-9\p{Pd}\u{002B}\u{00A9}-\u{1f9ff}_. ]+$/u, - msg: __("Name can contain only letters, digits, emojis, '_', '.', '+', dashes, or spaces"), - }, -]; +export const START_RULE = { + reg: /^[a-zA-Z0-9\u{00A9}-\u{1f9ff}_]/u, + msg: __('Name must start with a letter, digit, emoji, or underscore.'), +}; + +export const CONTAINS_RULE = { + reg: /^[a-zA-Z0-9\p{Pd}\u{002B}\u{00A9}-\u{1f9ff}_. ]+$/u, + msg: __( + 'Name can contain only lowercase or uppercase letters, digits, emojis, spaces, dots, underscores, dashes, or pluses.', + ), +}; + +const rulesReg = [START_RULE, CONTAINS_RULE]; /** * * @param {string} text * @returns {string} msg */ -function checkRules(text) { +export const checkRules = (text) => { for (const item of rulesReg) { if (!item.reg.test(text)) { return item.msg; } } return ''; -} - -export { checkRules }; +}; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index d71e80dffcf..99ea02aaa4f 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -90,13 +90,16 @@ const validateGroupNamespaceDropdown = (e) => { const checkProjectName = (projectNameInput) => { const msg = checkRules(projectNameInput.value); - const projectNameError = document.querySelector('#project_name_error'); + const projectNameError = document.querySelector('#js-project-name-error'); + const projectNameDescription = document.getElementById('js-project-name-description'); if (!projectNameError) return; if (msg) { projectNameError.innerText = msg; - projectNameError.classList.remove('hidden'); + projectNameError.classList.remove('gl-display-none'); + projectNameDescription.classList.add('gl-display-none'); } else { - projectNameError.classList.add('hidden'); + projectNameError.classList.add('gl-display-none'); + projectNameDescription.classList.remove('gl-display-none'); } }; diff --git a/app/assets/javascripts/projects/project_visibility.js b/app/assets/javascripts/projects/project_visibility.js index 84b8936c17f..2dd5f821d90 100644 --- a/app/assets/javascripts/projects/project_visibility.js +++ b/app/assets/javascripts/projects/project_visibility.js @@ -44,21 +44,6 @@ function setVisibilityOptions({ name, visibility, showPath, editPath }) { }); } -function handleSelect2DropdownChange(namespaceSelector) { - if (!namespaceSelector || !('selectedIndex' in namespaceSelector)) { - return; - } - const selectedNamespace = namespaceSelector.options[namespaceSelector.selectedIndex]; - setVisibilityOptions(selectedNamespace.dataset); -} - export default function initProjectVisibilitySelector() { eventHub.$on('update-visibility', setVisibilityOptions); - - const namespaceSelector = document.querySelector('select.js-select-namespace'); - if (namespaceSelector) { - const el = document.querySelector('.select2.js-select-namespace'); - el.addEventListener('change', () => handleSelect2DropdownChange(namespaceSelector)); - handleSelect2DropdownChange(namespaceSelector); - } } diff --git a/app/assets/javascripts/projects/prune_objects_button.js b/app/assets/javascripts/projects/prune_objects_button.js new file mode 100644 index 00000000000..dba73f6a19d --- /dev/null +++ b/app/assets/javascripts/projects/prune_objects_button.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import PruneUnreachableObjectsButton from './prune_unreachable_objects_button.vue'; + +export default (selector = '#js-project-prune-unreachable-objects-button') => { + const el = document.querySelector(selector); + + if (!el) return; + + const { pruneObjectsPath, pruneObjectsDocPath } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + render(createElement) { + return createElement(PruneUnreachableObjectsButton, { + props: { + pruneObjectsPath, + pruneObjectsDocPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/projects/prune_unreachable_objects_button.vue b/app/assets/javascripts/projects/prune_unreachable_objects_button.vue new file mode 100644 index 00000000000..1387fbb78c0 --- /dev/null +++ b/app/assets/javascripts/projects/prune_unreachable_objects_button.vue @@ -0,0 +1,75 @@ +<script> +import { GlButton, GlLink, GlModal, GlModalDirective } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { s__ } from '~/locale'; + +export default { + components: { + GlButton, + GlLink, + GlModal, + }, + PRUNE_UNREACHABLE_OBJECTS_MODAL_ID: 'prune-objects-modal', + MODAL_ACTION_PRIMARY: { + text: s__('UpdateProject|Prune'), + attributes: [{ variant: 'danger' }], + }, + MODAL_ACTION_CANCEL: { + text: s__('UpdateProject|Cancel'), + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + pruneObjectsPath: { + type: String, + required: true, + }, + pruneObjectsDocPath: { + type: String, + required: true, + }, + }, + computed: { + csrfToken() { + return csrf.token; + }, + }, + methods: { + submitForm() { + this.$refs.form.submit(); + }, + }, +}; +</script> + +<template> + <form ref="form" :action="pruneObjectsPath" method="post"> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + <input value="true" type="hidden" name="prune" /> + <gl-modal + :modal-id="$options.PRUNE_UNREACHABLE_OBJECTS_MODAL_ID" + :title="s__('UpdateProject|Are you sure you want to prune unreachable objects?')" + :action-primary="$options.MODAL_ACTION_PRIMARY" + :action-cancel="$options.MODAL_ACTION_CANCEL" + size="sm" + :no-focus-on-show="true" + @ok="submitForm" + > + <p> + {{ s__('UpdateProject|Pruning unreachable objects can lead to repository corruption.') }} + <gl-link :href="pruneObjectsDocPath" target="_blank"> + {{ s__('UpdateProject|Learn more.') }} + </gl-link> + {{ s__('UpdateProject|Are you sure you want to prune?') }} + </p> + </gl-modal> + <gl-button + v-gl-modal="$options.PRUNE_UNREACHABLE_OBJECTS_MODAL_ID" + category="primary" + variant="danger" + > + {{ s__('UpdateProject|Prune unreachable objects') }} + </gl-button> + </form> +</template> diff --git a/app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue b/app/assets/javascripts/projects/report_abuse/components/report_abuse_dropdown_item.vue index 31890249f41..ff76ca7c862 100644 --- a/app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue +++ b/app/assets/javascripts/projects/report_abuse/components/report_abuse_dropdown_item.vue @@ -12,6 +12,7 @@ export default { MountingPortal, AbuseCategorySelector, }, + inject: ['reportedUserId', 'reportedFromUrl'], i18n: { reportAbuse: s__('ReportAbuse|Report abuse to administrator'), }, @@ -21,21 +22,23 @@ export default { }; }, methods: { - openDrawer() { - this.open = true; - }, - closeDrawer() { - this.open = false; + toggleDrawer(open) { + this.open = open; }, }, }; </script> <template> <span> - <gl-dropdown-item @click="openDrawer">{{ $options.i18n.reportAbuse }}</gl-dropdown-item> + <gl-dropdown-item @click="toggleDrawer(true)">{{ $options.i18n.reportAbuse }}</gl-dropdown-item> <mounting-portal mount-to="#js-report-abuse-drawer" name="abuse-category-selector" append> - <abuse-category-selector :show-drawer="open" @close-drawer="closeDrawer" /> + <abuse-category-selector + :reported-user-id="reportedUserId" + :reported-from-url="reportedFromUrl" + :show-drawer="open" + @close-drawer="toggleDrawer(false)" + /> </mounting-portal> </span> </template> diff --git a/app/assets/javascripts/projects/report_abuse/index.js b/app/assets/javascripts/projects/report_abuse/index.js new file mode 100644 index 00000000000..9bcfdbf6165 --- /dev/null +++ b/app/assets/javascripts/projects/report_abuse/index.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import ReportAbuseDropdownItem from './components/report_abuse_dropdown_item.vue'; + +export const initReportAbuse = () => { + const items = document.querySelectorAll('.js-report-abuse-dropdown-item'); + + items.forEach((el) => { + if (!el) return false; + + const { reportAbusePath, reportedUserId, reportedFromUrl } = el.dataset; + + return new Vue({ + el, + name: 'ReportAbuseDropdownItemRoot', + provide: { + reportAbusePath, + reportedUserId: parseInt(reportedUserId, 10), + reportedFromUrl, + }, + render(createElement) { + return createElement(ReportAbuseDropdownItem); + }, + }); + }); +}; diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue index 541923bb735..95e140f30a9 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue @@ -4,7 +4,7 @@ import { s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; export const i18n = { - allowedToPush: s__('BranchRules|Allowed to push'), + allowedToPush: s__('BranchRules|Allowed to push and merge'), forcePushTitle: s__( 'BranchRules|Allow all users with push access to %{linkStart}force push%{linkEnd}.', ), diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js index 61c37a2348a..a98c2439cde 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js @@ -1,10 +1,10 @@ import { s__ } from '~/locale'; export const I18N = { - manageProtectionsLinkTitle: s__('BranchRules|Manage in Protected Branches'), - targetBranch: s__('BranchRules|Target Branch'), + manageProtectionsLinkTitle: s__('BranchRules|Manage in protected branches'), + targetBranch: s__('BranchRules|Target branch'), branchNameOrPattern: s__('BranchRules|Branch name or pattern'), - branch: s__('BranchRules|Target Branch'), + branch: s__('BranchRules|Target branch'), allBranches: s__('BranchRules|All branches'), matchingBranchesLinkTitle: s__('BranchRules|%{total} matching %{subject}'), protectBranchTitle: s__('BranchRules|Protect branch'), @@ -20,7 +20,7 @@ export const I18N = { ), disallowForcePushDescription: s__('BranchRules|Force push is not allowed.'), approvalsTitle: s__('BranchRules|Approvals'), - manageApprovalsLinkTitle: s__('BranchRules|Manage in Merge Request Approvals'), + manageApprovalsLinkTitle: s__('BranchRules|Manage in merge request approvals'), approvalsDescription: s__( 'BranchRules|Approvals to ensure separation of duties for new merge requests. %{linkStart}Learn more.%{linkEnd}', ), @@ -28,9 +28,9 @@ export const I18N = { statusChecksDescription: s__( 'BranchRules|Check for a status response in merge requests. Failures do not block merges. %{linkStart}Learn more.%{linkEnd}', ), - statusChecksLinkTitle: s__('BranchRules|Manage in Status checks'), + statusChecksLinkTitle: s__('BranchRules|Manage in status checks'), statusChecksHeader: s__('BranchRules|Status checks (%{total})'), - allowedToPushHeader: s__('BranchRules|Allowed to push (%{total})'), + allowedToPushHeader: s__('BranchRules|Allowed to push and merge (%{total})'), allowedToMergeHeader: s__('BranchRules|Allowed to merge (%{total})'), approvalsHeader: s__('BranchRules|Required approvals (%{total})'), noData: s__('BranchRules|No data to display'), diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue index 6260c8dd4d0..740868e1d75 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue @@ -3,7 +3,7 @@ import { GlSprintf, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { sprintf, n__ } from '~/locale'; import { getParameterByName, mergeUrlParams } from '~/lib/utils/url_utility'; import { helpPagePath } from '~/helpers/help_page_helper'; -import branchRulesQuery from '../../queries/branch_rules_details.query.graphql'; +import branchRulesQuery from 'ee_else_ce/projects/settings/branch_rules/queries/branch_rules_details.query.graphql'; import { getAccessLevels } from '../../../utils'; import Protection from './protection.vue'; import { @@ -12,22 +12,16 @@ import { BRANCH_PARAM_NAME, WILDCARDS_HELP_PATH, PROTECTED_BRANCHES_HELP_PATH, - APPROVALS_HELP_PATH, - STATUS_CHECKS_HELP_PATH, } from './constants'; const wildcardsHelpDocLink = helpPagePath(WILDCARDS_HELP_PATH); const protectedBranchesHelpDocLink = helpPagePath(PROTECTED_BRANCHES_HELP_PATH); -const approvalsHelpDocLink = helpPagePath(APPROVALS_HELP_PATH); -const statusChecksHelpDocLink = helpPagePath(STATUS_CHECKS_HELP_PATH); export default { name: 'RuleView', i18n: I18N, wildcardsHelpDocLink, protectedBranchesHelpDocLink, - approvalsHelpDocLink, - statusChecksHelpDocLink, components: { Protection, GlSprintf, GlLink, GlLoadingIcon }, inject: { projectPath: { @@ -36,12 +30,6 @@ export default { protectedBranchesPath: { default: '', }, - approvalRulesPath: { - default: '', - }, - statusChecksPath: { - default: '', - }, branchesPath: { default: '', }, @@ -58,7 +46,7 @@ export default { const branchRule = branchRules.nodes.find((rule) => rule.name === this.branch); this.branchRule = branchRule; this.branchProtection = branchRule?.branchProtection; - this.approvalRules = branchRule?.approvalRules; + this.approvalRules = branchRule?.approvalRules?.nodes || []; this.statusChecks = branchRule?.externalStatusChecks?.nodes || []; this.matchingBranchesCount = branchRule?.matchingBranchesCount; }, @@ -98,20 +86,6 @@ export default { total: this.pushAccessLevels?.total || 0, }); }, - approvalsHeader() { - const total = this.approvals.reduce( - (sum, { approvalsRequired }) => sum + approvalsRequired, - 0, - ); - return sprintf(this.$options.i18n.approvalsHeader, { - total, - }); - }, - statusChecksHeader() { - return sprintf(this.$options.i18n.statusChecksHeader, { - total: this.statusChecks.length, - }); - }, allBranches() { return this.branch === ALL_BRANCHES_WILDCARD; }, @@ -131,8 +105,13 @@ export default { const subject = n__('branch', 'branches', total); return sprintf(this.$options.i18n.matchingBranchesLinkTitle, { total, subject }); }, - approvals() { - return this.approvalRules?.nodes || []; + // needed to override EE component + statusChecksHeader() { + return ''; + }, + // needed to override EE component + approvalsHeader() { + return ''; }, }, methods: { @@ -199,40 +178,46 @@ export default { :groups="mergeAccessLevels.groups" /> + <!-- EE start --> <!-- Approvals --> - <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.approvalsTitle }}</h4> - <gl-sprintf :message="$options.i18n.approvalsDescription"> - <template #link="{ content }"> - <gl-link :href="$options.approvalsHelpDocLink"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> + <template v-if="approvalsHeader"> + <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.approvalsTitle }}</h4> + <gl-sprintf :message="$options.i18n.approvalsDescription"> + <template #link="{ content }"> + <gl-link :href="$options.approvalsHelpDocLink"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> - <protection - class="gl-mt-3" - :header="approvalsHeader" - :header-link-title="$options.i18n.manageApprovalsLinkTitle" - :header-link-href="approvalRulesPath" - :approvals="approvals" - /> + <protection + class="gl-mt-3" + :header="approvalsHeader" + :header-link-title="$options.i18n.manageApprovalsLinkTitle" + :header-link-href="approvalRulesPath" + :approvals="approvalRules" + /> + </template> <!-- Status checks --> - <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.statusChecksTitle }}</h4> - <gl-sprintf :message="$options.i18n.statusChecksDescription"> - <template #link="{ content }"> - <gl-link :href="$options.statusChecksHelpDocLink"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> + <template v-if="statusChecksHeader"> + <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.statusChecksTitle }}</h4> + <gl-sprintf :message="$options.i18n.statusChecksDescription"> + <template #link="{ content }"> + <gl-link :href="$options.statusChecksHelpDocLink"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> - <protection - class="gl-mt-3" - :header="statusChecksHeader" - :header-link-title="$options.i18n.statusChecksLinkTitle" - :header-link-href="statusChecksPath" - :status-checks="statusChecks" - /> + <protection + class="gl-mt-3" + :header="statusChecksHeader" + :header-link-title="$options.i18n.statusChecksLinkTitle" + :header-link-href="statusChecksPath" + :status-checks="statusChecks" + /> + </template> + <!-- EE end --> </div> </template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js index 7639acc1181..081d6cec958 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js +++ b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import View from './components/view/index.vue'; +import View from 'ee_else_ce/projects/settings/branch_rules/components/view/index.vue'; export default function mountBranchRules(el) { if (!el) { diff --git a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql index a832e59aa67..aa736469749 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql +++ b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql @@ -4,24 +4,14 @@ query getBranchRulesDetails($projectPath: ID!) { branchRules { nodes { name + matchingBranchesCount branchProtection { allowForcePush - codeOwnerApprovalRequired mergeAccessLevels { edges { node { accessLevel accessLevelDescription - group { - id - avatarUrl - } - user { - id - name - avatarUrl - webUrl - } } } } @@ -30,45 +20,10 @@ query getBranchRulesDetails($projectPath: ID!) { node { accessLevel accessLevelDescription - group { - id - avatarUrl - } - user { - id - name - avatarUrl - webUrl - } - } - } - } - } - approvalRules { - nodes { - id - name - type - approvalsRequired - eligibleApprovers { - nodes { - id - name - username - webUrl - avatarUrl } } } } - externalStatusChecks { - nodes { - id - name - externalUrl - } - } - matchingBranchesCount } } } diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue index 9b669024a8b..f3d392a0ec4 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue @@ -1,23 +1,22 @@ <script> -import { s__ } from '~/locale'; +import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; import { createAlert } from '~/flash'; import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql'; +import { expandSection } from '~/settings_panels'; +import { scrollToElement } from '~/lib/utils/common_utils'; import BranchRule from './components/branch_rule.vue'; - -export const i18n = { - queryError: s__( - 'ProtectedBranch|An error occurred while loading branch rules. Please try again.', - ), - emptyState: s__( - 'ProtectedBranch|Protected branches, merge request approvals, and status checks will appear here once configured.', - ), -}; +import { I18N, PROTECTED_BRANCHES_ANCHOR, BRANCH_PROTECTION_MODAL_ID } from './constants'; export default { name: 'BranchRules', - i18n, + i18n: I18N, components: { BranchRule, + GlButton, + GlModal, + }, + directives: { + GlModal: GlModalDirective, }, apollo: { branchRules: { @@ -36,20 +35,27 @@ export default { }, }, inject: { - projectPath: { - default: '', - }, + projectPath: { default: '' }, }, data() { return { branchRules: [], }; }, + methods: { + showProtectedBranches() { + // Protected branches section is on the same page as the branch rules section. + expandSection(this.$options.protectedBranchesAnchor); + scrollToElement(this.$options.protectedBranchesAnchor); + }, + }, + modalId: BRANCH_PROTECTION_MODAL_ID, + protectedBranchesAnchor: PROTECTED_BRANCHES_ANCHOR, }; </script> <template> - <div class="settings-content"> + <div class="settings-content gl-mb-0"> <branch-rule v-for="(rule, index) in branchRules" :key="`${rule.name}-${index}`" @@ -61,6 +67,21 @@ export default { :matching-branches-count="rule.matchingBranchesCount" /> - <span v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</span> + <div v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</div> + + <gl-button v-gl-modal="$options.modalId" class="gl-mt-5" category="secondary" variant="info">{{ + $options.i18n.addBranchRule + }}</gl-button> + + <gl-modal + :ref="$options.modalId" + :modal-id="$options.modalId" + :title="$options.i18n.addBranchRule" + :ok-title="$options.i18n.createProtectedBranch" + @ok="showProtectedBranches" + > + <p>{{ $options.i18n.branchRuleModalDescription }}</p> + <p>{{ $options.i18n.branchRuleModalContent }}</p> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue index 4a24df4b0dc..fa96eee5f92 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue @@ -13,7 +13,7 @@ export const i18n = { approvalRules: s__('BranchRules|%{total} approval %{subject}'), matchingBranches: s__('BranchRules|%{total} matching %{subject}'), pushAccessLevels: s__('BranchRules|Allowed to merge'), - mergeAccessLevels: s__('BranchRules|Allowed to push'), + mergeAccessLevels: s__('BranchRules|Allowed to push and merge'), }; export default { @@ -106,7 +106,7 @@ export default { }, approvalDetails() { const approvalDetails = []; - if (this.isWildcard) { + if (this.isWildcard || this.matchingBranchesCount > 1) { approvalDetails.push(this.matchingBranchesText); } if (this.branchProtection?.allowForcePush) { diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js b/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js new file mode 100644 index 00000000000..4413d8eab4e --- /dev/null +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js @@ -0,0 +1,22 @@ +import { s__ } from '~/locale'; + +export const I18N = { + queryError: s__( + 'ProtectedBranch|An error occurred while loading branch rules. Please try again.', + ), + emptyState: s__( + 'ProtectedBranch|After you configure a protected branch, merge request approval, or status check, it appears here.', + ), + addBranchRule: s__('BranchRules|Add branch rule'), + branchRuleModalDescription: s__( + 'BranchRules|To create a branch rule, you first need to create a protected branch.', + ), + branchRuleModalContent: s__( + 'BranchRules|After a protected branch is created, it will show up in the list as a branch rule.', + ), + createProtectedBranch: s__('BranchRules|Create protected branch'), +}; + +export const PROTECTED_BRANCHES_ANCHOR = '#js-protected-branches-settings'; + +export const BRANCH_PROTECTION_MODAL_ID = 'addBranchRuleModal'; diff --git a/app/assets/javascripts/projects/settings/utils.js b/app/assets/javascripts/projects/settings/utils.js index 7bcfde39178..ea4574119c0 100644 --- a/app/assets/javascripts/projects/settings/utils.js +++ b/app/assets/javascripts/projects/settings/utils.js @@ -9,7 +9,7 @@ export const getAccessLevels = (accessLevels = {}) => { } else if (node.group) { accessLevelTypes.groups.push(node); } else { - accessLevelTypes.roles.push(node); + accessLevelTypes.roles.push({ accessLevelDescription: node.accessLevelDescription }); } }); diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue deleted file mode 100644 index 52d1ed96b21..00000000000 --- a/app/assets/javascripts/ref/components/ref_results_section.vue +++ /dev/null @@ -1,138 +0,0 @@ -<script> -import { GlDropdownSectionHeader, GlDropdownItem, GlBadge, GlIcon } from '@gitlab/ui'; -import { s__ } from '~/locale'; - -export default { - name: 'RefResultsSection', - components: { - GlDropdownSectionHeader, - GlDropdownItem, - GlBadge, - GlIcon, - }, - props: { - showHeader: { - type: Boolean, - required: false, - default: true, - }, - - sectionTitle: { - type: String, - required: true, - }, - - totalCount: { - type: Number, - required: true, - }, - - /** - * An array of object that have the following properties: - * - * - name (String, required): The name of the ref that will be displayed - * - value (String, optional): The value that will be selected when the ref - * is selected. If not provided, `name` will be used as the value. - * For example, commits use the short SHA for `name` - * and long SHA for `value`. - * - subtitle (String, optional): Text to render underneath the name. - * For example, used to render the commit's title underneath its SHA. - * - default (Boolean, optional): Whether or not to render a "default" - * indicator next to the item. Used to indicate - * the project's default branch. - * - */ - items: { - type: Array, - required: true, - validator: (items) => Array.isArray(items) && items.every((item) => item.name), - }, - - /** - * The currently selected ref. - * Used to render a check mark by the selected item. - * */ - selectedRef: { - type: String, - required: false, - default: '', - }, - - /** - * An error object that indicates that an error - * occurred while fetching items for this section - */ - error: { - type: Error, - required: false, - default: null, - }, - - /** The message to display if an error occurs */ - errorMessage: { - type: String, - required: false, - default: '', - }, - shouldShowCheck: { - type: Boolean, - required: false, - default: true, - }, - }, - computed: { - totalCountText() { - return this.totalCount > 999 ? s__('TotalRefCountIndicator|1000+') : `${this.totalCount}`; - }, - }, - methods: { - showCheck(item) { - if (!this.shouldShowCheck) { - return false; - } - return item.name === this.selectedRef || item.value === this.selectedRef; - }, - }, -}; -</script> - -<template> - <div> - <gl-dropdown-section-header v-if="showHeader"> - <div class="gl-display-flex align-items-center" data-testid="section-header"> - <span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span> - <gl-badge variant="neutral">{{ totalCountText }}</gl-badge> - </div> - </gl-dropdown-section-header> - <template v-if="error"> - <div class="gl-display-flex align-items-start text-danger gl-ml-4 gl-mr-4 gl-mb-3"> - <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" /> - <span>{{ errorMessage }}</span> - </div> - </template> - <template v-else> - <gl-dropdown-item - v-for="item in items" - :key="item.name" - @click="$emit('selected', item.value || item.name)" - > - <div class="gl-display-flex align-items-start"> - <gl-icon - name="mobile-issue-close" - class="gl-mr-2 gl-flex-shrink-0" - :class="{ 'gl-visibility-hidden': !showCheck(item) }" - /> - - <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column"> - <span class="gl-font-monospace">{{ item.name }}</span> - <span class="gl-text-gray-400">{{ item.subtitle }}</span> - </div> - - <gl-badge v-if="item.default" size="sm" variant="info">{{ - s__('DefaultBranchLabel|default') - }}</gl-badge> - </div> - </gl-dropdown-item> - </template> - </div> -</template> diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index 10967fb84ed..359909b8f3b 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -1,13 +1,8 @@ <script> -import { - GlDropdown, - GlDropdownDivider, - GlSearchBoxByType, - GlSprintf, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlBadge, GlIcon, GlCollapsibleListbox } from '@gitlab/ui'; import { debounce, isArray } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; +import { sprintf } from '~/locale'; import { ALL_REF_TYPES, SEARCH_DEBOUNCE_MS, @@ -15,21 +10,16 @@ import { REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS, - BRANCH_REF_TYPE, - TAG_REF_TYPE, } from '../constants'; import createStore from '../stores'; -import RefResultsSection from './ref_results_section.vue'; +import { formatListBoxItems, formatErrors } from '../format_refs'; export default { name: 'RefSelector', components: { - GlDropdown, - GlDropdownDivider, - GlSearchBoxByType, - GlSprintf, - GlLoadingIcon, - RefResultsSection, + GlBadge, + GlIcon, + GlCollapsibleListbox, }, inheritAttrs: false, props: { @@ -87,6 +77,11 @@ export default { required: false, default: '', }, + toggleButtonClass: { + type: [String, Object, Array], + required: false, + default: null, + }, }, data() { return { @@ -106,35 +101,33 @@ export default { ...this.translations, }; }, - showBranchesSection() { - return ( - this.enabledRefTypes.includes(REF_TYPE_BRANCHES) && - Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error) - ); + listBoxItems() { + return formatListBoxItems(this.branches, this.tags, this.commits); }, - showTagsSection() { - return ( - this.enabledRefTypes.includes(REF_TYPE_TAGS) && - Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error) - ); + branches() { + return this.enabledRefTypes.includes(REF_TYPE_BRANCHES) ? this.matches.branches.list : []; }, - showCommitsSection() { - return ( - this.enabledRefTypes.includes(REF_TYPE_COMMITS) && - Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error) - ); + tags() { + return this.enabledRefTypes.includes(REF_TYPE_TAGS) ? this.matches.tags.list : []; }, - showNoResults() { - return !this.showBranchesSection && !this.showTagsSection && !this.showCommitsSection; + commits() { + return this.enabledRefTypes.includes(REF_TYPE_COMMITS) ? this.matches.commits.list : []; }, - showSectionHeaders() { - return this.enabledRefTypes.length > 1; - }, - toggleButtonClass() { - return { - 'gl-inset-border-1-red-500!': !this.state, - 'gl-font-monospace': Boolean(this.selectedRef), - }; + extendedToggleButtonClass() { + const classes = [ + { + 'gl-inset-border-1-red-500!': !this.state, + 'gl-font-monospace': Boolean(this.selectedRef), + }, + ]; + + if (Array.isArray(this.toggleButtonClass)) { + classes.push(...this.toggleButtonClass); + } else { + classes.push(this.toggleButtonClass); + } + + return classes; }, footerSlotProps() { return { @@ -143,6 +136,9 @@ export default { query: this.lastQuery, }; }, + errors() { + return formatErrors(this.matches.branches, this.matches.tags, this.matches.commits); + }, selectedRefForDisplay() { if (this.useSymbolicRefNames && this.selectedRef) { return this.selectedRef.replace(/^refs\/(tags|heads)\//, ''); @@ -153,11 +149,12 @@ export default { buttonText() { return this.selectedRefForDisplay || this.i18n.noRefSelected; }, - isTagRefType() { - return this.refType === TAG_REF_TYPE; - }, - isBranchRefType() { - return this.refType === BRANCH_REF_TYPE; + noResultsMessage() { + return this.lastQuery + ? sprintf(this.i18n.noResultsWithQuery, { + query: this.lastQuery, + }) + : this.i18n.noResults; }, }, watch: { @@ -185,9 +182,7 @@ export default { // because we need to access the .cancel() method // lodash attaches to the function, which is // made inaccessible by Vue. - this.debouncedSearch = debounce(function search() { - this.search(); - }, SEARCH_DEBOUNCE_MS); + this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS); this.setProjectId(this.projectId); @@ -214,14 +209,8 @@ export default { 'setSelectedRef', ]), ...mapActions({ storeSearch: 'search' }), - focusSearchBox() { - this.$refs.searchBox.$el.querySelector('input').focus(); - }, - onSearchBoxEnter() { - this.debouncedSearch.cancel(); - this.search(); - }, - onSearchBoxInput() { + onSearchBoxInput(searchQuery = '') { + this.query = searchQuery?.trim(); this.debouncedSearch(); }, selectRef(ref) { @@ -231,104 +220,55 @@ export default { search() { this.storeSearch(this.query); }, + totalCountText(count) { + return count > 999 ? this.i18n.totalCountLabel : `${count}`; + }, }, }; </script> <template> <div> - <gl-dropdown - :header-text="i18n.dropdownHeader" - :toggle-class="toggleButtonClass" - :text="buttonText" + <gl-collapsible-listbox class="ref-selector gl-w-full" + block + searchable + :selected="selectedRef" + :header-text="i18n.dropdownHeader" + :items="listBoxItems" + :no-results-text="noResultsMessage" + :searching="isLoading" + :search-placeholder="i18n.searchPlaceholder" + :toggle-class="extendedToggleButtonClass" + :toggle-text="buttonText" v-bind="$attrs" v-on="$listeners" - @shown="focusSearchBox" + @hidden="$emit('hide')" + @search="onSearchBoxInput" + @select="selectRef" > - <template #header> - <gl-search-box-by-type - ref="searchBox" - v-model.trim="query" - :placeholder="i18n.searchPlaceholder" - autocomplete="off" - data-qa-selector="ref_selector_searchbox" - @input="onSearchBoxInput" - @keydown.enter.prevent="onSearchBoxEnter" - /> + <template #group-label="{ group }"> + {{ group.text }} <gl-badge size="sm">{{ totalCountText(group.options.length) }}</gl-badge> </template> - - <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" /> - - <div - v-else-if="showNoResults" - class="gl-text-center gl-mx-3 gl-py-3" - data-testid="no-results" - > - <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery"> - <template #query> - <b class="gl-word-break-all">{{ lastQuery }}</b> - </template> - </gl-sprintf> - - <span v-else>{{ i18n.noResults }}</span> - </div> - - <template v-else> - <template v-if="showBranchesSection"> - <ref-results-section - :section-title="i18n.branches" - :total-count="matches.branches.totalCount" - :items="matches.branches.list" - :selected-ref="selectedRef" - :error="matches.branches.error" - :error-message="i18n.branchesErrorMessage" - :show-header="showSectionHeaders" - data-testid="branches-section" - data-qa-selector="branches_section" - :should-show-check="!useSymbolicRefNames || isBranchRefType" - @selected="selectRef($event)" - /> - - <gl-dropdown-divider v-if="showTagsSection || showCommitsSection" /> - </template> - - <template v-if="showTagsSection"> - <ref-results-section - :section-title="i18n.tags" - :total-count="matches.tags.totalCount" - :items="matches.tags.list" - :selected-ref="selectedRef" - :error="matches.tags.error" - :error-message="i18n.tagsErrorMessage" - :show-header="showSectionHeaders" - data-testid="tags-section" - :should-show-check="!useSymbolicRefNames || isTagRefType" - @selected="selectRef($event)" - /> - - <gl-dropdown-divider v-if="showCommitsSection" /> - </template> - - <template v-if="showCommitsSection"> - <ref-results-section - :section-title="i18n.commits" - :total-count="matches.commits.totalCount" - :items="matches.commits.list" - :selected-ref="selectedRef" - :error="matches.commits.error" - :error-message="i18n.commitsErrorMessage" - :show-header="showSectionHeaders" - data-testid="commits-section" - @selected="selectRef($event)" - /> - </template> + <template #list-item="{ item }"> + {{ item.text }} + <gl-badge v-if="item.default" size="sm" variant="info">{{ + i18n.defaultLabelText + }}</gl-badge> </template> - <template #footer> <slot name="footer" v-bind="footerSlotProps"></slot> + <div + v-for="errorMessage in errors" + :key="errorMessage" + data-testid="red-selector-error-list" + class="gl-display-flex gl-align-items-flex-start gl-text-red-500 gl-mx-4 gl-my-3" + > + <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" /> + <span>{{ errorMessage }}</span> + </div> </template> - </gl-dropdown> + </gl-collapsible-listbox> <input v-if="name" data-testid="selected-ref-form-field" diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js index f4faa535166..4b5b18cf6c1 100644 --- a/app/assets/javascripts/ref/constants.js +++ b/app/assets/javascripts/ref/constants.js @@ -1,5 +1,5 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { __ } from '~/locale'; +import { s__, __ } from '~/locale'; export const REF_TYPE_BRANCHES = 'REF_TYPE_BRANCHES'; export const REF_TYPE_TAGS = 'REF_TYPE_TAGS'; @@ -13,6 +13,7 @@ export const X_TOTAL_HEADER = 'x-total'; export const SEARCH_DEBOUNCE_MS = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; export const DEFAULT_I18N = Object.freeze({ + defaultLabelText: __('default'), dropdownHeader: __('Select Git revision'), searchPlaceholder: __('Search by Git revision'), noResultsWithQuery: __('No matching results for "%{query}"'), @@ -24,4 +25,5 @@ export const DEFAULT_I18N = Object.freeze({ tags: __('Tags'), commits: __('Commits'), noRefSelected: __('No ref selected'), + totalCountLabel: s__('TotalRefCountIndicator|1000+'), }); diff --git a/app/assets/javascripts/ref/format_refs.js b/app/assets/javascripts/ref/format_refs.js new file mode 100644 index 00000000000..af310a35ef4 --- /dev/null +++ b/app/assets/javascripts/ref/format_refs.js @@ -0,0 +1,60 @@ +import { DEFAULT_I18N } from './constants'; + +function convertToListBoxItems(items) { + return items.map((item) => ({ + text: item.name, + value: item.value || item.name, + default: item.default, + })); +} + +/** + * Format multiple lists to array of group options for listbox + * @param branches list of branches + * @param tags list of tags + * @param commits list of commits + * @returns {*[]} array of group items with header and options + */ +export const formatListBoxItems = (branches, tags, commits) => { + const listBoxItems = []; + + const addToFinalResult = (items, header) => { + if (items && items.length > 0) { + listBoxItems.push({ + text: header, + options: convertToListBoxItems(items), + }); + } + }; + + addToFinalResult(branches, DEFAULT_I18N.branches); + addToFinalResult(tags, DEFAULT_I18N.tags); + addToFinalResult(commits, DEFAULT_I18N.commits); + + return listBoxItems; +}; + +/** + * Check error existence and add to final array + * @param branches list of branches + * @param tags list of tags + * @param commits list of commits + * @returns {*[]} array of error messages + */ +export const formatErrors = (branches, tags, commits) => { + const errorsList = []; + + if (branches && branches.error) { + errorsList.push(DEFAULT_I18N.branchesErrorMessage); + } + + if (tags && tags.error) { + errorsList.push(DEFAULT_I18N.tagsErrorMessage); + } + + if (commits && commits.error) { + errorsList.push(DEFAULT_I18N.commitsErrorMessage); + } + + return errorsList; +}; 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 102f1228355..adae92a92e9 100644 --- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -1,10 +1,10 @@ <script> import { GlFormGroup, GlFormRadioGroup, GlButton } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { - issuableTypesMap, itemAddFailureTypesMap, linkedIssueTypesMap, addRelatedIssueErrorMap, @@ -54,7 +54,7 @@ export default { issuableType: { type: String, required: false, - default: issuableTypesMap.ISSUE, + default: TYPE_ISSUE, }, hasError: { type: Boolean, diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue index 09ecad2d90e..8d6a3110f35 100644 --- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue +++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue @@ -1,11 +1,11 @@ <script> import $ from 'jquery'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; +import { TYPE_ISSUE } from '~/issues/constants'; import { autoCompleteTextMap, inputPlaceholderConfidentialTextMap, inputPlaceholderTextMap, - issuableTypesMap, } from '../constants'; import IssueToken from './issue_token.vue'; @@ -54,7 +54,7 @@ export default { issuableType: { type: String, required: false, - default: issuableTypesMap.ISSUE, + default: TYPE_ISSUE, }, confidential: { type: Boolean, diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue index 11de734f5d4..7387b9ab87c 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -2,6 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import Sortable from 'sortablejs'; import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; +import { TYPE_ISSUE } from '~/issues/constants'; import { defaultSortableOptions } from '~/sortable/constants'; export default { @@ -88,7 +89,7 @@ export default { document.body.classList.remove('is-dragging'); }, issuableOrderingId({ epicIssueId, id }) { - return this.issuableType === 'issue' ? epicIssueId : id; + return this.issuableType === TYPE_ISSUE ? epicIssueId : id; }, }, }; 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 795eb3b0083..ed70e1ce8a8 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_root.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue @@ -25,12 +25,13 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways: */ import { createAlert } from '~/flash'; import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/issues/constants'; +import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status'; import { __ } from '~/locale'; import { relatedIssuesRemoveErrorMap, pathIndeterminateErrorMap, addRelatedIssueErrorMap, - issuableTypesMap, PathIdSeparator, } from '../constants'; import RelatedIssuesService from '../services/related_issues_service'; @@ -65,7 +66,7 @@ export default { issuableType: { type: String, required: false, - default: issuableTypesMap.ISSUE, + default: TYPE_ISSUE, }, allowAutoComplete: { type: Boolean, @@ -142,7 +143,7 @@ export default { this.store.setRelatedIssues(data.issuables); }) .catch((res) => { - if (res && res.status !== 404) { + if (res && res.status !== HTTP_STATUS_NOT_FOUND) { createAlert({ message: relatedIssuesRemoveErrorMap[this.issuableType] }); } }); diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js index d1b2d41d7ae..2a4ce70511b 100644 --- a/app/assets/javascripts/related_issues/constants.js +++ b/app/assets/javascripts/related_issues/constants.js @@ -1,4 +1,5 @@ import { __, sprintf } from '~/locale'; +import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants'; export const issuableTypesMap = { ISSUE: 'issue', @@ -21,7 +22,7 @@ export const linkedIssueTypesTextMap = { export const autoCompleteTextMap = { true: { - [issuableTypesMap.ISSUE]: sprintf( + [TYPE_ISSUE]: sprintf( __(' or %{emphasisStart}#issue id%{emphasisEnd}'), { emphasisStart: '<', emphasisEnd: '>' }, false, @@ -31,7 +32,7 @@ export const autoCompleteTextMap = { { emphasisStart: '<', emphasisEnd: '>' }, false, ), - [issuableTypesMap.EPIC]: sprintf( + [TYPE_EPIC]: sprintf( __(' or %{emphasisStart}&epic id%{emphasisEnd}'), { emphasisStart: '<', emphasisEnd: '>' }, false, @@ -43,33 +44,33 @@ export const autoCompleteTextMap = { ), }, false: { - [issuableTypesMap.ISSUE]: '', - [issuableTypesMap.EPIC]: '', - [issuableTypesMap.MERGE_REQUEST]: __(' or references (e.g. path/to/project!merge_request_id)'), + [TYPE_ISSUE]: '', + [TYPE_EPIC]: '', + [issuableTypesMap.MERGE_REQUEST]: __(' or references'), }, }; export const inputPlaceholderTextMap = { - [issuableTypesMap.ISSUE]: __('Paste issue link'), + [TYPE_ISSUE]: __('Paste issue link'), [issuableTypesMap.INCIDENT]: __('Paste link'), - [issuableTypesMap.EPIC]: __('Paste epic link'), + [TYPE_EPIC]: __('Paste epic link'), [issuableTypesMap.MERGE_REQUEST]: __('Enter merge request URLs'), }; export const inputPlaceholderConfidentialTextMap = { - [issuableTypesMap.ISSUE]: __('Paste confidential issue link'), - [issuableTypesMap.EPIC]: __('Paste confidential epic link'), + [TYPE_ISSUE]: __('Paste confidential issue link'), + [TYPE_EPIC]: __('Paste confidential epic link'), [issuableTypesMap.MERGE_REQUEST]: __('Enter merge request URLs'), }; export const relatedIssuesRemoveErrorMap = { - [issuableTypesMap.ISSUE]: __('An error occurred while removing issues.'), - [issuableTypesMap.EPIC]: __('An error occurred while removing epics.'), + [TYPE_ISSUE]: __('An error occurred while removing issues.'), + [TYPE_EPIC]: __('An error occurred while removing epics.'), }; export const pathIndeterminateErrorMap = { - [issuableTypesMap.ISSUE]: __('We could not determine the path to remove the issue'), - [issuableTypesMap.EPIC]: __('We could not determine the path to remove the epic'), + [TYPE_ISSUE]: __('We could not determine the path to remove the issue'), + [TYPE_EPIC]: __('We could not determine the path to remove the epic'), }; export const itemAddFailureTypesMap = { @@ -78,8 +79,8 @@ export const itemAddFailureTypesMap = { }; export const addRelatedIssueErrorMap = { - [issuableTypesMap.ISSUE]: __('Issue cannot be found.'), - [issuableTypesMap.EPIC]: __('Epic cannot be found.'), + [TYPE_ISSUE]: __('Issue cannot be found.'), + [TYPE_EPIC]: __('Epic cannot be found.'), }; export const addRelatedItemErrorMap = { @@ -94,9 +95,9 @@ export const addRelatedItemErrorMap = { * them inside i18n functions. */ export const issuableIconMap = { - [issuableTypesMap.ISSUE]: 'issues', + [TYPE_ISSUE]: 'issues', [issuableTypesMap.INCIDENT]: 'issues', - [issuableTypesMap.EPIC]: 'epic', + [TYPE_EPIC]: 'epic', }; export const PathIdSeparator = { @@ -105,30 +106,30 @@ export const PathIdSeparator = { }; export const issuablesBlockHeaderTextMap = { - [issuableTypesMap.ISSUE]: __('Linked items'), + [TYPE_ISSUE]: __('Linked items'), [issuableTypesMap.INCIDENT]: __('Linked incidents or issues'), - [issuableTypesMap.EPIC]: __('Linked epics'), + [TYPE_EPIC]: __('Linked epics'), }; export const issuablesBlockHelpTextMap = { - [issuableTypesMap.ISSUE]: __('Learn more about linking issues'), + [TYPE_ISSUE]: __('Learn more about linking issues'), [issuableTypesMap.INCIDENT]: __('Learn more about linking issues and incidents'), - [issuableTypesMap.EPIC]: __('Learn more about linking epics'), + [TYPE_EPIC]: __('Learn more about linking epics'), }; export const issuablesBlockAddButtonTextMap = { - [issuableTypesMap.ISSUE]: __('Add a related issue'), - [issuableTypesMap.EPIC]: __('Add a related epic'), + [TYPE_ISSUE]: __('Add a related issue'), + [TYPE_EPIC]: __('Add a related epic'), }; export const issuablesFormCategoryHeaderTextMap = { - [issuableTypesMap.ISSUE]: __('The current issue'), + [TYPE_ISSUE]: __('The current issue'), [issuableTypesMap.INCIDENT]: __('The current incident'), - [issuableTypesMap.EPIC]: __('The current epic'), + [TYPE_EPIC]: __('The current epic'), }; export const issuablesFormInputTextMap = { - [issuableTypesMap.ISSUE]: __('the following issues'), + [TYPE_ISSUE]: __('the following issues'), [issuableTypesMap.INCIDENT]: __('the following incidents or issues'), - [issuableTypesMap.EPIC]: __('the following epics'), + [TYPE_EPIC]: __('the following epics'), }; diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js index c77a67c4287..cc00ef10dda 100644 --- a/app/assets/javascripts/related_issues/index.js +++ b/app/assets/javascripts/related_issues/index.js @@ -1,9 +1,10 @@ import Vue from 'vue'; -import apolloProvider from '~/issues/show/graphql'; +import { TYPE_ISSUE } from '~/issues/constants'; +import { apolloProvider } from '~/graphql_shared/issuable_client'; import { parseBoolean } from '~/lib/utils/common_utils'; import RelatedIssuesRoot from './components/related_issues_root.vue'; -export function initRelatedIssues(issueType = 'issue') { +export function initRelatedIssues(issueType = TYPE_ISSUE) { const el = document.querySelector('.js-related-issues-root'); if (!el) { diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 965b9fa09d6..ff92cdd42c6 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -13,6 +13,7 @@ import { isSameOriginUrl, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; +import { putCreateReleaseNotification } from '~/releases/release_notification_service'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import AssetLinksForm from './asset_links_form.vue'; import ConfirmDeleteModal from './confirm_delete_modal.vue'; @@ -49,6 +50,7 @@ export default { 'newMilestonePath', 'manageMilestonesPath', 'projectId', + 'projectPath', 'groupId', 'groupMilestonesAvailable', 'tagNotes', @@ -150,6 +152,7 @@ export default { submitForm() { if (!this.isFormSubmissionDisabled) { this.saveRelease(); + putCreateReleaseNotification(this.projectPath, this.release.name); } }, }, @@ -161,7 +164,7 @@ export default { <gl-sprintf :message=" __( - 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0.0%{codeEnd}, %{codeStart}v2.1.0-pre%{codeEnd}.', + 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}1.0.0%{codeEnd}, %{codeStart}2.1.0-pre%{codeEnd}.', ) " > diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index 1b360b79b0c..9f200856db3 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -244,21 +244,19 @@ export default { </script> <template> <div class="gl-display-flex gl-flex-direction-column gl-mt-3"> - <div class="gl-align-self-end gl-mb-3"> + <releases-empty-state v-if="shouldRenderEmptyState" /> + <div v-else class="gl-align-self-end gl-mb-3"> <releases-sort :value="sort" class="gl-mr-2" @input="onSortChanged" /> <gl-button v-if="newReleasePath" :href="newReleasePath" - :aria-describedby="shouldRenderEmptyState && 'releases-description'" category="primary" variant="confirm" >{{ $options.i18n.newRelease }}</gl-button > </div> - <releases-empty-state v-if="shouldRenderEmptyState" /> - <release-block v-for="(release, index) in releases" :key="getReleaseKey(release, index)" diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue index 7147cfa01c8..544f2de5132 100644 --- a/app/assets/javascripts/releases/components/app_show.vue +++ b/app/assets/javascripts/releases/components/app_show.vue @@ -1,6 +1,7 @@ <script> import { createAlert } from '~/flash'; import { s__ } from '~/locale'; +import { popCreateReleaseNotification } from '~/releases/release_notification_service'; import oneReleaseQuery from '../graphql/queries/one_release.query.graphql'; import { convertGraphQLRelease } from '../util'; import ReleaseBlock from './release_block.vue'; @@ -49,6 +50,9 @@ export default { }, }, }, + mounted() { + popCreateReleaseNotification(this.fullPath); + }, methods: { showFlash(error) { createAlert({ diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue index 6d415471b14..2118c26fd81 100644 --- a/app/assets/javascripts/releases/components/evidence_block.vue +++ b/app/assets/javascripts/releases/components/evidence_block.vue @@ -67,12 +67,13 @@ export default { <gl-link v-gl-tooltip class="d-flex align-items-center monospace" - :title="__('Download evidence JSON')" - :download="evidenceTitle(index)" + target="_blank" + :title="__('Open evidence JSON in new tab')" :href="evidenceUrl(index)" > <gl-icon name="review-list" class="align-middle gl-mr-3" /> <span>{{ evidenceTitle(index) }}</span> + <gl-icon name="external-link" class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0" /> </gl-link> <expand-button> diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue index 1761f4360d1..cc28980a6bf 100644 --- a/app/assets/javascripts/releases/components/release_block_assets.vue +++ b/app/assets/javascripts/releases/components/release_block_assets.vue @@ -121,7 +121,7 @@ export default { <gl-icon :name="section.iconName" class="gl-mr-2 gl-flex-shrink-0 gl-flex-grow-0" /> {{ link.name }} <gl-icon - v-if="link.external" + v-if="section.title" v-gl-tooltip name="external-link" :aria-label="$options.externalLinkTooltipText" diff --git a/app/assets/javascripts/releases/components/releases_empty_state.vue b/app/assets/javascripts/releases/components/releases_empty_state.vue index 800497c186a..ae94bd6872e 100644 --- a/app/assets/javascripts/releases/components/releases_empty_state.vue +++ b/app/assets/javascripts/releases/components/releases_empty_state.vue @@ -1,44 +1,33 @@ <script> -import { GlEmptyState, GlLink } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlEmptyState } from '@gitlab/ui'; +import { s__ } from '~/locale'; export default { name: 'ReleasesEmptyState', components: { GlEmptyState, - GlLink, - }, - inject: { - documentationPath: { - default: '', - }, - illustrationPath: { - default: '', - }, }, + inject: ['documentationPath', 'illustrationPath', 'newReleasePath'], i18n: { - emptyStateTitle: __('Getting started with releases'), - emptyStateText: __( - "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.", + emptyStateTitle: s__('Release|Getting started with releases'), + emptyStateText: s__( + "Release|Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.", ), - releasesDocumentation: __('Releases documentation'), - moreInformation: __('More information'), + releasesDocumentation: s__('Release|Learn more about releases'), + moreInformation: s__('Release|More information'), + newRelease: s__('Release|Create a new release'), }, }; </script> <template> - <gl-empty-state :title="$options.i18n.emptyStateTitle" :svg-path="illustrationPath"> - <template #description> - <span id="releases-description"> - {{ $options.i18n.emptyStateText }} - <gl-link - :href="documentationPath" - :aria-label="$options.i18n.releasesDocumentation" - target="_blank" - > - {{ $options.i18n.moreInformation }} - </gl-link> - </span> - </template> - </gl-empty-state> + <gl-empty-state + class="gl-layout-w-limited" + :title="$options.i18n.emptyStateTitle" + :description="$options.i18n.emptyStateText" + :svg-path="illustrationPath" + :primary-button-link="newReleasePath" + :primary-button-text="$options.i18n.newRelease" + :secondary-button-link="documentationPath" + :secondary-button-text="$options.i18n.releasesDocumentation" + /> </template> diff --git a/app/assets/javascripts/releases/release_notification_service.js b/app/assets/javascripts/releases/release_notification_service.js new file mode 100644 index 00000000000..a4f926d7561 --- /dev/null +++ b/app/assets/javascripts/releases/release_notification_service.js @@ -0,0 +1,23 @@ +import { s__, sprintf } from '~/locale'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; + +const createReleaseSessionKey = (projectPath) => `createRelease:${projectPath}`; + +export const putCreateReleaseNotification = (projectPath, releaseName) => { + window.sessionStorage.setItem(createReleaseSessionKey(projectPath), releaseName); +}; + +export const popCreateReleaseNotification = (projectPath) => { + const key = createReleaseSessionKey(projectPath); + const createdRelease = window.sessionStorage.getItem(key); + + if (createdRelease) { + createAlert({ + message: sprintf(s__('Release|Release %{createdRelease} has been successfully created.'), { + createdRelease, + }), + variant: VARIANT_SUCCESS, + }); + window.sessionStorage.removeItem(key); + } +}; 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 f80e75501c9..ccd168aafc9 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js @@ -40,6 +40,7 @@ export default { [types.UPDATE_RELEASE_TAG_NAME](state, tagName) { state.release.tagName = tagName; + state.existingRelease = null; }, [types.UPDATE_RELEASE_TAG_MESSAGE](state, tagMessage) { state.release.tagMessage = tagMessage; @@ -118,6 +119,7 @@ export default { state.fetchError = error; state.isFetchingTagNotes = false; state.tagNotes = ''; + state.existingRelease = null; }, [types.UPDATE_INCLUDE_TAG_NOTES](state, includeTagNotes) { state.includeTagNotes = includeTagNotes; diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js index a480710f8ac..68b2cf6f3da 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/index.js +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -4,7 +4,7 @@ const viewers = { image: () => import('./image_viewer.vue'), video: () => import('./video_viewer.vue'), empty: () => import('./empty_viewer.vue'), - text: () => import('~/vue_shared/components/source_viewer/source_viewer.vue'), + text: () => import('~/vue_shared/components/source_viewer/source_viewer_deprecated.vue'), pdf: () => import('./pdf_viewer.vue'), lfs: () => import('./lfs_viewer.vue'), audio: () => import('./audio_viewer.vue'), diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue index 980fa140eb5..9804837b200 100644 --- a/app/assets/javascripts/repository/components/fork_info.vue +++ b/app/assets/javascripts/repository/components/fork_info.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import { GlIcon, GlLink, GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; import { s__, sprintf, n__ } from '~/locale'; import { createAlert } from '~/flash'; import forkDetailsQuery from '../queries/fork_details.query.graphql'; @@ -9,9 +9,9 @@ export const i18n = { inaccessibleProject: s__('ForkedFromProjectPath|Forked from an inaccessible project.'), upToDate: s__('ForksDivergence|Up to date with the upstream repository.'), unknown: s__('ForksDivergence|This fork has diverged from the upstream repository.'), - behind: s__('ForksDivergence|%{behind} %{commit_word} behind'), - ahead: s__('ForksDivergence|%{ahead} %{commit_word} ahead of'), - behindAndAhead: s__('ForksDivergence|%{messages} the upstream repository.'), + behind: s__('ForksDivergence|%{behindLinkStart}%{behind} %{commit_word} behind%{behindLinkEnd}'), + ahead: s__('ForksDivergence|%{aheadLinkStart}%{ahead} %{commit_word} ahead%{aheadLinkEnd} of'), + behindAhead: s__('ForksDivergence|%{messages} the upstream repository.'), error: s__('ForksDivergence|Failed to fetch fork details. Try again later.'), }; @@ -20,6 +20,7 @@ export default { components: { GlIcon, GlLink, + GlSprintf, GlSkeletonLoader, }, apollo: { @@ -28,7 +29,7 @@ export default { variables() { return { projectPath: this.projectPath, - ref: this.selectedRef, + ref: this.selectedBranch, }; }, skip() { @@ -48,7 +49,7 @@ export default { type: String, required: true, }, - selectedRef: { + selectedBranch: { type: String, required: true, }, @@ -62,6 +63,16 @@ export default { required: false, default: '', }, + aheadComparePath: { + type: String, + required: false, + default: '', + }, + behindComparePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -116,7 +127,7 @@ export default { return this.$options.i18n.unknown; } if (this.hasBehindAheadMessage) { - return sprintf(this.$options.i18n.behindAndAhead, { + return sprintf(this.$options.i18n.behindAhead, { messages: this.behindAheadMessage, }); } @@ -134,8 +145,15 @@ export default { {{ $options.i18n.forkedFrom }} <gl-link data-qa-selector="forked_from_link" :href="sourcePath">{{ sourceName }}</gl-link> <gl-skeleton-loader v-if="isLoading" :lines="1" /> - <div v-else class="gl-text-secondary"> - {{ forkDivergenceMessage }} + <div v-else class="gl-text-secondary" data-testid="divergence-message"> + <gl-sprintf :message="forkDivergenceMessage"> + <template #aheadLink="{ content }"> + <gl-link :href="aheadComparePath">{{ content }}</gl-link> + </template> + <template #behindLink="{ content }"> + <gl-link :href="behindComparePath">{{ content }}</gl-link> + </template> + </gl-sprintf> </div> </div> <div v-else data-testid="inaccessible-project" class="gl-align-items-center gl-display-flex"> diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index 8feac6b8e35..90949536cc1 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -14,7 +14,6 @@ export default { url: this.blob.webPath, }; }, - loadingKey: 'loading', }, }, components: { @@ -34,9 +33,13 @@ export default { data() { return { readme: null, - loading: 0, }; }, + computed: { + isLoading() { + return this.$apollo.queries.readme.loading; + }, + }, watch: { readme(newVal) { if (newVal) { @@ -64,7 +67,7 @@ export default { </div> </div> <div class="blob-viewer" data-qa-selector="blob_viewer_content" itemprop="about"> - <gl-loading-icon v-if="loading > 0" size="lg" color="dark" class="my-4 mx-auto" /> + <gl-loading-icon v-if="isLoading" size="lg" color="dark" class="my-4 mx-auto" /> <div v-else-if="readme" ref="readme" diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 27ac11f3c58..6dd059a349f 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -203,7 +203,7 @@ export default { :is="linkComponent" ref="link" v-gl-hover-load="handlePreload" - v-gl-tooltip:tooltip-container + v-gl-tooltip="{ placement: 'left', boundary: 'viewport' }" :title="fullPath" :to="routerLinkTo" :href="url" diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index e5d22f50d72..494e270a66c 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -69,7 +69,7 @@ export default function setupVueRepositoryList() { if (!forkEl) { return null; } - const { sourceName, sourcePath } = forkEl.dataset; + const { sourceName, sourcePath, aheadComparePath, behindComparePath } = forkEl.dataset; return new Vue({ el: forkEl, apolloProvider, @@ -77,9 +77,11 @@ export default function setupVueRepositoryList() { return h(ForkInfo, { props: { projectPath, - selectedRef: ref, + selectedBranch: ref, sourceName, sourcePath, + aheadComparePath, + behindComparePath, }, }); }, @@ -131,7 +133,7 @@ export default function setupVueRepositoryList() { }, on: { input(selectedRef) { - visitUrl(generateRefDestinationPath(projectRootPath, selectedRef)); + visitUrl(generateRefDestinationPath(projectRootPath, ref, selectedRef)); }, }, }); diff --git a/app/assets/javascripts/repository/mixins/highlight_mixin.js b/app/assets/javascripts/repository/mixins/highlight_mixin.js new file mode 100644 index 00000000000..95d0c55bb04 --- /dev/null +++ b/app/assets/javascripts/repository/mixins/highlight_mixin.js @@ -0,0 +1,106 @@ +import { nextTick } from 'vue'; +import { + LEGACY_FALLBACKS, + EVENT_ACTION, + EVENT_LABEL_FALLBACK, + LINES_PER_CHUNK, +} from '~/vue_shared/components/source_viewer/constants'; +import { splitIntoChunks } from '~/vue_shared/components/source_viewer/workers/highlight_utils'; +import LineHighlighter from '~/blob/line_highlighter'; +import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import Tracking from '~/tracking'; +import { TEXT_FILE_TYPE } from '../constants'; + +/* + * This mixin is intended to be used as an interface between our highlight worker and Vue components + */ +export default { + mixins: [Tracking.mixin()], + inject: { + highlightWorker: { default: null }, + }, + data() { + return { + chunks: [], + }; + }, + methods: { + trackEvent(label, language) { + this.track(EVENT_ACTION, { label, property: language }); + }, + isUnsupportedLanguage(language) { + const supportedLanguages = Object.keys(languageLoader); + const isUnsupportedLanguage = !supportedLanguages.includes(language); + + return LEGACY_FALLBACKS.includes(language) || isUnsupportedLanguage; + }, + handleUnsupportedLanguage(language) { + this.trackEvent(EVENT_LABEL_FALLBACK, language); + this?.onError(); + }, + initHighlightWorker({ rawTextBlob, language, simpleViewer }) { + if (simpleViewer?.fileType !== TEXT_FILE_TYPE) return; + + if (this.isUnsupportedLanguage(language)) { + this.handleUnsupportedLanguage(language); + return; + } + + /* + * We want to start rendering content as soon as possible, but highlighting large amounts of + * content can take long, so we render the content in phases: + * + * 1. `splitIntoChunks` with the first 70 lines of raw text. + * This ensures that we start rendering raw content in the DOM as soon as we can so that + * the user can see content as fast as possible (improves perceived performance and LCP). + * 2. `instructWorker` to start highlighting the first 70 lines. + * This ensures that we display highlighted** content to the user as fast as possible + * (improves perceived performance and makes the first 70 lines look nice). + * 3. `instructWorker` to start highlighting all the content. + * This is the longest task. It ensures that we highlight all content, since the first 70 + * lines are already rendered, this can happen in the background. + */ + + // Render the first 70 lines (raw text) ASAP, this improves perceived performance and LCP. + const firstSeventyLines = rawTextBlob.split(/\r?\n/).slice(0, LINES_PER_CHUNK).join('\n'); + + this.chunks = splitIntoChunks(language, firstSeventyLines); + + this.highlightWorker.onmessage = this.handleWorkerMessage; + + // Instruct the worker to highlight the first 70 lines ASAP, this improves perceived performance. + this.instructWorker(firstSeventyLines, language); + + // Instruct the worker to start highlighting all lines in the background. + this.instructWorker(rawTextBlob, language); + }, + handleWorkerMessage({ data }) { + this.chunks = data; + this.highlightHash(); // highlight the line if a line number hash is present in the URL + }, + instructWorker(content, language) { + this.highlightWorker.postMessage({ content, language }); + }, + async highlightHash() { + const { hash } = this.$route; + if (!hash) return; + + // Make the chunk containing the line number visible + const lineNumber = hash.substring(hash.indexOf('L') + 1).split('-')[0]; + const chunkToHighlight = this.chunks.find( + (chunk) => + chunk.startingFrom <= lineNumber && chunk.startingFrom + chunk.totalLines >= lineNumber, + ); + + if (chunkToHighlight) { + chunkToHighlight.isHighlighted = true; + } + + // Line numbers in the DOM needs to update first based on changes made to `chunks`. + await nextTick(); + + const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + lineHighlighter.highlightHash(hash); + }, + }, +}; diff --git a/app/assets/javascripts/repository/utils/ref_switcher_utils.js b/app/assets/javascripts/repository/utils/ref_switcher_utils.js index f296b5e9b4a..c62f7f709c4 100644 --- a/app/assets/javascripts/repository/utils/ref_switcher_utils.js +++ b/app/assets/javascripts/repository/utils/ref_switcher_utils.js @@ -5,9 +5,9 @@ import { joinPaths } from '~/lib/utils/url_utility'; * Example: /root/Flight/-/blob/fix/main/test/spec/utils_spec.js * Group 1: /-/blob * Group 2: blob - * Group 3: main/test/spec/utils_spec.js + * Group 3: /test/spec/utils_spec.js */ -const NAMESPACE_TARGET_REGEX = /(\/-\/(blob|tree))\/.*?\/(.*)/; +const getNamespaceTargetRegex = (ref) => new RegExp(`(/-/(blob|tree))/${ref}/(.*)`); /** * Generates a ref destination path based on the selected ref and current path. @@ -15,11 +15,12 @@ const NAMESPACE_TARGET_REGEX = /(\/-\/(blob|tree))\/.*?\/(.*)/; * @param {string} projectRootPath - The root path for a project. * @param {string} selectedRef - The selected ref from the ref dropdown. */ -export function generateRefDestinationPath(projectRootPath, selectedRef) { +export function generateRefDestinationPath(projectRootPath, ref, selectedRef) { const currentPath = window.location.pathname; const encodedHash = '%23'; let namespace = '/-/tree'; let target; + const NAMESPACE_TARGET_REGEX = getNamespaceTargetRegex(ref); const match = NAMESPACE_TARGET_REGEX.exec(currentPath); if (match) { [, namespace, , target] = match; diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js index 7b5babdd3a6..87996d0bb85 100644 --- a/app/assets/javascripts/rest_api.js +++ b/app/assets/javascripts/rest_api.js @@ -7,6 +7,7 @@ export * from './api/namespaces_api'; export * from './api/tags_api'; export * from './api/alert_management_alerts_api'; export * from './api/harbor_registry'; +export * from './api/environments_api'; // Note: It's not possible to spy on methods imported from this file in // Jest tests. diff --git a/app/assets/javascripts/saved_replies/components/app.vue b/app/assets/javascripts/saved_replies/components/app.vue new file mode 100644 index 00000000000..db8476c44f3 --- /dev/null +++ b/app/assets/javascripts/saved_replies/components/app.vue @@ -0,0 +1,23 @@ +<script> +export default {}; +</script> + +<template> + <div class="row gl-mt-5"> + <div class="col-lg-4"> + <h4 class="gl-mt-0"> + {{ __('Saved Replies') }} + </h4> + <p> + {{ + __( + 'Saved replies can be used when creating comments inside issues, merge requests, and epics.', + ) + }} + </p> + </div> + <div class="col-lg-8"> + <router-view /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/saved_replies/components/list.vue b/app/assets/javascripts/saved_replies/components/list.vue new file mode 100644 index 00000000000..30089cfa53f --- /dev/null +++ b/app/assets/javascripts/saved_replies/components/list.vue @@ -0,0 +1,57 @@ +<script> +import { GlKeysetPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import savedRepliesQuery from '../queries/saved_replies.query.graphql'; +import ListItem from './list_item.vue'; + +export default { + apollo: { + savedReplies: { + query: savedRepliesQuery, + update: (r) => r.currentUser?.savedReplies?.nodes, + result({ data }) { + const pageInfo = data.currentUser?.savedReplies?.pageInfo; + + this.count = data.currentUser?.savedReplies?.count; + + if (pageInfo) { + this.pageInfo = pageInfo; + } + }, + }, + }, + components: { + GlLoadingIcon, + GlKeysetPagination, + GlSprintf, + ListItem, + }, + data() { + return { + savedReplies: [], + count: 0, + pageInfo: {}, + }; + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="$apollo.queries.savedReplies.loading" size="lg" /> + <template v-else> + <h5 class="gl-font-lg" data-testid="title"> + <gl-sprintf :message="__('My saved replies (%{count})')"> + <template #count>{{ count }}</template> + </gl-sprintf> + </h5> + <ul class="gl-list-style-none gl-p-0 gl-m-0"> + <list-item v-for="reply in savedReplies" :key="reply.id" :reply="reply" /> + </ul> + <gl-keyset-pagination + v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage" + v-bind="pageInfo" + class="gl-mt-4" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/saved_replies/components/list_item.vue b/app/assets/javascripts/saved_replies/components/list_item.vue new file mode 100644 index 00000000000..dfa9a405dee --- /dev/null +++ b/app/assets/javascripts/saved_replies/components/list_item.vue @@ -0,0 +1,19 @@ +<script> +export default { + props: { + reply: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <li class="gl-mb-5"> + <div class="gl-display-flex gl-align-items-center"> + <strong>{{ reply.name }}</strong> + </div> + <div class="gl-mt-3 gl-font-monospace">{{ reply.content }}</div> + </li> +</template> diff --git a/app/assets/javascripts/saved_replies/index.js b/app/assets/javascripts/saved_replies/index.js new file mode 100644 index 00000000000..5022ff62b10 --- /dev/null +++ b/app/assets/javascripts/saved_replies/index.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import routes from './routes'; +import App from './components/app.vue'; + +export const initSavedReplies = () => { + Vue.use(VueApollo); + Vue.use(VueRouter); + + const el = document.getElementById('js-saved-replies-root'); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + const router = new VueRouter({ + base: el.dataset.basePath, + mode: 'history', + routes, + }); + + // eslint-disable-next-line no-new + new Vue({ + el, + router, + apolloProvider, + render(h) { + return h(App); + }, + }); +}; diff --git a/app/assets/javascripts/saved_replies/pages/index.vue b/app/assets/javascripts/saved_replies/pages/index.vue new file mode 100644 index 00000000000..38f51dbc365 --- /dev/null +++ b/app/assets/javascripts/saved_replies/pages/index.vue @@ -0,0 +1,15 @@ +<script> +import List from '../components/list.vue'; + +export default { + components: { + List, + }, +}; +</script> + +<template> + <div> + <list /> + </div> +</template> diff --git a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql b/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql new file mode 100644 index 00000000000..af1f12f3ceb --- /dev/null +++ b/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql @@ -0,0 +1,19 @@ +query savedReplies { + currentUser { + id + savedReplies { + nodes { + id + name + content + } + count + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + } + } +} diff --git a/app/assets/javascripts/saved_replies/routes.js b/app/assets/javascripts/saved_replies/routes.js new file mode 100644 index 00000000000..bd582a5ed86 --- /dev/null +++ b/app/assets/javascripts/saved_replies/routes.js @@ -0,0 +1,8 @@ +import IndexComponent from './pages/index.vue'; + +export default [ + { + path: '/', + component: IndexComponent, + }, +]; diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index d4ee857c9c1..d71785d7fac 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -1,6 +1,5 @@ import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result'; import { queryToObject } from '~/lib/utils/url_utility'; -import refreshCounts from '~/pages/search/show/refresh_counts'; import syntaxHighlight from '~/syntax_highlight'; import { initSidebar, sidebarInitState } from './sidebar'; import { initSearchSort } from './sort'; @@ -24,8 +23,4 @@ export const initSearchApp = () => { setHighlightClass(query.search); // Code Highlighting initBlobRefSwitcher(); // Code Search Branch Picker - - if (!gon.features?.searchPageVerticalNav) { - refreshCounts(); // Other Scope Tab Counts - } }; diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index 6f29864c0a2..2efc80fef75 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -2,28 +2,34 @@ import { mapState } from 'vuex'; import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS } from '../constants'; +import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB } from '../constants'; import ResultsFilters from './results_filters.vue'; +import LanguageFilter from './language_filter.vue'; export default { name: 'GlobalSearchSidebar', components: { ResultsFilters, ScopeNavigation, + LanguageFilter, }, mixins: [glFeatureFlagsMixin()], computed: { ...mapState(['urlQuery']), - showFilters() { + showIssueAndMergeFilters() { return this.urlQuery.scope === SCOPE_ISSUES || this.urlQuery.scope === SCOPE_MERGE_REQUESTS; }, + showBlobFilter() { + return this.urlQuery.scope === SCOPE_BLOB && this.glFeatures.searchBlobsLanguageAggregation; + }, }, }; </script> <template> <section class="search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4 gl-mb-6 gl-mt-5"> - <scope-navigation v-if="glFeatures.searchPageVerticalNav" /> - <results-filters v-if="showFilters" /> + <scope-navigation /> + <results-filters v-if="showIssueAndMergeFilters" /> + <language-filter v-if="showBlobFilter" /> </section> </template> diff --git a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue new file mode 100644 index 00000000000..b580d58b21b --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue @@ -0,0 +1,81 @@ +<script> +import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { intersection } from 'lodash'; +import { NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES } from '../constants'; +import { formatSearchResultCount } from '../../store/utils'; + +export default { + name: 'CheckboxFilter', + components: { + GlFormCheckboxGroup, + GlFormCheckbox, + }, + props: { + filterData: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['query']), + scope() { + return this.query.scope; + }, + queryFilters() { + return this.query[this.filterData?.filterParam] || []; + }, + dataFilters() { + return Object.values(this.filterData?.filters || []); + }, + flatDataFilterValues() { + return this.dataFilters.map(({ value }) => value); + }, + selectedFilter: { + get() { + return intersection(this.flatDataFilterValues, this.queryFilters); + }, + set(value) { + this.setQuery({ key: this.filterData?.filterParam, value }); + }, + }, + labelCountClasses() { + return [...NAV_LINK_COUNT_DEFAULT_CLASSES, 'gl-text-gray-500']; + }, + }, + methods: { + ...mapActions(['setQuery']), + getFormatedCount(count) { + return formatSearchResultCount(count); + }, + }, + NAV_LINK_COUNT_DEFAULT_CLASSES, + LABEL_DEFAULT_CLASSES, +}; +</script> + +<template> + <div class="gl-mx-5"> + <h5 class="gl-mt-0">{{ filterData.header }}</h5> + <gl-form-checkbox-group v-model="selectedFilter"> + <gl-form-checkbox + v-for="f in dataFilters" + :key="f.label" + :value="f.label" + class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full" + :class="$options.LABEL_DEFAULT_CLASSES" + > + <span + class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full" + > + <span data-testid="label"> + {{ f.label }} + </span> + <span v-if="f.count" :class="labelCountClasses" data-testid="labelCount"> + {{ getFormatedCount(f.count) }} + </span> + </span> + </gl-form-checkbox> + </gl-form-checkbox-group> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue index fbfc24a94ae..e7aa3d61409 100644 --- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue @@ -1,5 +1,4 @@ <script> -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { confidentialFilterData } from '../constants/confidential_filter_data'; import RadioFilter from './radio_filter.vue'; @@ -8,19 +7,13 @@ export default { components: { RadioFilter, }, - mixins: [glFeatureFlagsMixin()], - computed: { - ffBasedXPadding() { - return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0'; - }, - }, confidentialFilterData, }; </script> <template> <div> - <radio-filter :class="ffBasedXPadding" :filter-data="$options.confidentialFilterData" /> + <radio-filter class="gl-px-5" :filter-data="$options.confidentialFilterData" /> <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/components/language_filter.vue b/app/assets/javascripts/search/sidebar/components/language_filter.vue new file mode 100644 index 00000000000..26ce204cb5c --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/language_filter.vue @@ -0,0 +1,122 @@ +<script> +import { GlButton, GlAlert, GlForm } from '@gitlab/ui'; +import { mapState, mapActions, mapGetters } from 'vuex'; +import { __, s__, sprintf } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from '../constants/language_filter_data'; +import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../constants'; +import { convertFiltersData } from '../utils'; +import CheckboxFilter from './checkbox_filter.vue'; + +export default { + name: 'LanguageFilter', + components: { + CheckboxFilter, + GlButton, + GlAlert, + GlForm, + }, + mixins: [glFeatureFlagsMixin()], + data() { + return { + showAll: false, + }; + }, + i18n: { + showMore: s__('GlobalSearch|Show more'), + apply: __('Apply'), + showingMax: sprintf(s__('GlobalSearch|Showing top %{maxItems}'), { maxItems: MAX_ITEM_LENGTH }), + loadError: s__('GlobalSearch|Aggregations load error.'), + }, + computed: { + ...mapState(['aggregations', 'sidebarDirty']), + ...mapGetters(['langugageAggregationBuckets']), + hasBuckets() { + return this.langugageAggregationBuckets.length > 0; + }, + filtersData() { + return convertFiltersData(this.shortenedLanguageFilters); + }, + shortenedLanguageFilters() { + if (!this.hasShowMore) { + return this.langugageAggregationBuckets; + } + if (this.showAll) { + return this.trimBuckets(MAX_ITEM_LENGTH); + } + return this.trimBuckets(DEFAULT_ITEM_LENGTH); + }, + hasShowMore() { + return this.langugageAggregationBuckets.length > DEFAULT_ITEM_LENGTH; + }, + hasOverMax() { + return this.langugageAggregationBuckets.length > MAX_ITEM_LENGTH; + }, + dividerClasses() { + return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD]; + }, + }, + async created() { + await this.fetchLanguageAggregation(); + }, + methods: { + ...mapActions(['applyQuery', 'fetchLanguageAggregation']), + onShowMore() { + this.showAll = true; + }, + trimBuckets(length) { + return this.langugageAggregationBuckets.slice(0, length); + }, + }, + HR_DEFAULT_CLASSES, +}; +</script> + +<template> + <gl-form + v-if="hasBuckets" + class="gl-pt-5 gl-md-pt-0 language-filter-checkbox" + @submit.prevent="applyQuery" + > + <hr :class="dividerClasses" /> + <div + v-if="!aggregations.error" + class="gl-overflow-x-hidden gl-overflow-y-auto" + :class="{ 'language-filter-max-height': showAll }" + > + <checkbox-filter class="gl-px-5" :filter-data="filtersData" /> + <span v-if="showAll && hasOverMax" data-testid="has-over-max-text">{{ + $options.i18n.showingMax + }}</span> + </div> + <gl-alert v-else class="gl-mx-5" variant="danger" :dismissible="false">{{ + $options.i18n.loadError + }}</gl-alert> + <div v-if="hasShowMore && !showAll" class="gl-px-5 language-filter-show-all"> + <gl-button + data-testid="show-more-button" + category="tertiary" + variant="link" + size="small" + button-text-classes="gl-font-sm" + @click="onShowMore" + > + {{ $options.i18n.showMore }} + </gl-button> + </div> + <div v-if="!aggregations.error"> + <hr :class="$options.HR_DEFAULT_CLASSES" /> + <div class="gl-display-flex gl-align-items-center gl-mt-4 gl-mx-5 gl-px-5"> + <gl-button + category="primary" + variant="confirm" + type="submit" + :disabled="!sidebarDirty" + data-testid="apply-button" + > + {{ $options.i18n.apply }} + </gl-button> + </div> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue index ff7a044736d..4d9cc9d6450 100644 --- a/app/assets/javascripts/search/sidebar/components/results_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue @@ -1,7 +1,6 @@ <script> import { GlButton, GlLink } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { confidentialFilterData } from '../constants/confidential_filter_data'; import { stateFilterData } from '../constants/state_filter_data'; import ConfidentialityFilter from './confidentiality_filter.vue'; @@ -15,24 +14,17 @@ export default { StatusFilter, ConfidentialityFilter, }, - mixins: [glFeatureFlagsMixin()], computed: { ...mapState(['urlQuery', 'sidebarDirty']), showReset() { return this.urlQuery.state || this.urlQuery.confidential; }, - searchPageVerticalNavFeatureFlag() { - return this.glFeatures.searchPageVerticalNav; - }, showConfidentialityFilter() { return Object.values(confidentialFilterData.scopes).includes(this.urlQuery.scope); }, showStatusFilter() { return Object.values(stateFilterData.scopes).includes(this.urlQuery.scope); }, - ffBasedXPadding() { - return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0'; - }, }, methods: { ...mapActions(['applyQuery', 'resetQuery']), @@ -42,13 +34,10 @@ export default { <template> <form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery"> - <hr - v-if="searchPageVerticalNavFeatureFlag" - class="gl-my-5 gl-mx-5 gl-border-gray-100 gl-display-none gl-md-display-block" - /> + <hr class="gl-my-5 gl-mx-5 gl-border-gray-100 gl-display-none gl-md-display-block" /> <status-filter v-if="showStatusFilter" /> <confidentiality-filter v-if="showConfidentialityFilter" /> - <div class="gl-display-flex gl-align-items-center gl-mt-4" :class="ffBasedXPadding"> + <div class="gl-display-flex gl-align-items-center gl-mt-4 gl-px-5"> <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> {{ __('Apply') }} </gl-button> diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue index 3c280a5d696..5863381e2ef 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue @@ -5,6 +5,7 @@ import { s__ } from '~/locale'; import Tracking from '~/tracking'; import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants'; import { formatSearchResultCount } from '../../store/utils'; +import { slugifyWithUnderscore } from '../../../lib/utils/text_utility'; export default { name: 'ScopeNavigation', @@ -46,6 +47,9 @@ export default { isActive(scope, index) { return this.urlQuery.scope ? this.urlQuery.scope === scope : index === 0; }, + qaSelectorValue(item) { + return `${slugifyWithUnderscore(item.label)}_tab`; + }, }, NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES, @@ -62,6 +66,7 @@ export default { class="gl-mb-1" :href="item.link" :active="isActive(scope, index)" + :data-qa-selector="qaSelectorValue(item)" @click="handleClick(scope)" ><span>{{ item.label }}</span ><span v-if="item.count" :class="countClasses(isActive(scope, index))"> diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue index 4da96a41ef7..c3deabfcc26 100644 --- a/app/assets/javascripts/search/sidebar/components/status_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue @@ -1,5 +1,4 @@ <script> -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { stateFilterData } from '../constants/state_filter_data'; import RadioFilter from './radio_filter.vue'; @@ -8,19 +7,13 @@ export default { components: { RadioFilter, }, - mixins: [glFeatureFlagsMixin()], - computed: { - ffBasedXPadding() { - return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0'; - }, - }, stateFilterData, }; </script> <template> <div> - <radio-filter :class="ffBasedXPadding" :filter-data="$options.stateFilterData" /> + <radio-filter class="gl-px-5" :filter-data="$options.stateFilterData" /> <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/constants/language_filter_data.js b/app/assets/javascripts/search/sidebar/constants/language_filter_data.js new file mode 100644 index 00000000000..df44a58a14b --- /dev/null +++ b/app/assets/javascripts/search/sidebar/constants/language_filter_data.js @@ -0,0 +1,18 @@ +import { s__ } from '~/locale'; + +export const DEFAULT_ITEM_LENGTH = 10; +export const MAX_ITEM_LENGTH = 100; + +const header = s__('GlobalSearch|Language'); + +const scopes = { + BLOBS: 'blobs', +}; + +const filterParam = 'language'; + +export const languageFilterData = { + header, + scopes, + filterParam, +}; diff --git a/app/assets/javascripts/search/sidebar/utils.js b/app/assets/javascripts/search/sidebar/utils.js new file mode 100644 index 00000000000..5c08ad2f959 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/utils.js @@ -0,0 +1,20 @@ +import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; + +export const convertFiltersData = (rawBuckets) => { + return rawBuckets.reduce( + (acc, bucket) => { + return { + ...acc, + filters: { + ...acc.filters, + [bucket.key.toUpperCase()]: { + label: bucket.key, + value: bucket.key, + count: bucket.count, + }, + }, + }; + }, + { ...languageFilterData, filters: {} }, + ); +}; diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index 2a1b744561d..fc0817be882 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -6,7 +6,13 @@ import { logError } from '~/lib/logger'; import { __ } from '~/locale'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants'; import * as types from './mutation_types'; -import { loadDataFromLS, setFrequentItemToLS, mergeById, isSidebarDirty } from './utils'; +import { + loadDataFromLS, + setFrequentItemToLS, + mergeById, + isSidebarDirty, + getAggregationsUrl, +} from './utils'; export const fetchGroups = ({ commit }, search) => { commit(types.REQUEST_GROUPS); @@ -95,7 +101,7 @@ export const setQuery = ({ state, commit }, { key, value }) => { }; export const applyQuery = ({ state }) => { - visitUrl(setUrlParams({ ...state.query, page: null })); + visitUrl(setUrlParams({ ...state.query, page: null }, window.location.href, false, true)); }; export const resetQuery = ({ state }) => { @@ -117,3 +123,16 @@ export const fetchSidebarCount = ({ commit, state }) => { }); return Promise.all(promises); }; + +export const fetchLanguageAggregation = ({ commit }) => { + commit(types.REQUEST_AGGREGATIONS); + return axios + .get(getAggregationsUrl()) + .then(({ data }) => { + commit(types.RECEIVE_AGGREGATIONS_SUCCESS, data); + }) + .catch((e) => { + logError(e); + commit(types.RECEIVE_AGGREGATIONS_ERROR); + }); +}; diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js index e4f67f624ca..ba4fe85db9d 100644 --- a/app/assets/javascripts/search/store/constants.js +++ b/app/assets/javascripts/search/store/constants.js @@ -1,5 +1,6 @@ import { stateFilterData } from '~/search/sidebar/constants/state_filter_data'; import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data'; +import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; export const MAX_FREQUENT_ITEMS = 5; @@ -9,6 +10,10 @@ export const GROUPS_LOCAL_STORAGE_KEY = 'global-search-frequent-groups'; export const PROJECTS_LOCAL_STORAGE_KEY = 'global-search-frequent-projects'; -export const SIDEBAR_PARAMS = [stateFilterData.filterParam, confidentialFilterData.filterParam]; +export const SIDEBAR_PARAMS = [ + stateFilterData.filterParam, + confidentialFilterData.filterParam, + languageFilterData.filterParam, +]; export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' }; diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js index 650af5fa55a..0278239c144 100644 --- a/app/assets/javascripts/search/store/getters.js +++ b/app/assets/javascripts/search/store/getters.js @@ -1,3 +1,4 @@ +import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; export const frequentGroups = (state) => { @@ -7,3 +8,11 @@ export const frequentGroups = (state) => { export const frequentProjects = (state) => { return state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY]; }; + +export const langugageAggregationBuckets = (state) => { + return ( + state.aggregations.data.find( + (aggregation) => aggregation.name === languageFilterData.filterParam, + )?.buckets || [] + ); +}; diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js index 511b93cad2b..4ffbadcd083 100644 --- a/app/assets/javascripts/search/store/mutation_types.js +++ b/app/assets/javascripts/search/store/mutation_types.js @@ -11,3 +11,7 @@ export const SET_SIDEBAR_DIRTY = 'SET_SIDEBAR_DIRTY'; export const LOAD_FREQUENT_ITEMS = 'LOAD_FREQUENT_ITEMS'; export const RECEIVE_NAVIGATION_COUNT = 'RECEIVE_NAVIGATION_COUNT'; + +export const REQUEST_AGGREGATIONS = 'REQUEST_AGGREGATIONS'; +export const RECEIVE_AGGREGATIONS_SUCCESS = 'RECEIVE_AGGREGATIONS_SUCCESS'; +export const RECEIVE_AGGREGATIONS_ERROR = 'RECEIVE_AGGREGATIONS_ERROR'; diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js index c1339845272..f9fd69d2211 100644 --- a/app/assets/javascripts/search/store/mutations.js +++ b/app/assets/javascripts/search/store/mutations.js @@ -36,4 +36,13 @@ export default { const item = { ...state.navigation[key], count }; state.navigation = { ...state.navigation, [key]: item }; }, + [types.REQUEST_AGGREGATIONS](state) { + state.aggregations = { fetching: true, error: false, data: [] }; + }, + [types.RECEIVE_AGGREGATIONS_SUCCESS](state, data) { + state.aggregations = { fetching: false, error: false, data: [...data] }; + }, + [types.RECEIVE_AGGREGATIONS_ERROR](state) { + state.aggregations = { fetching: false, error: true, data: [] }; + }, }; diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index b64231a8688..d85a135bb4e 100644 --- a/app/assets/javascripts/search/store/state.js +++ b/app/assets/javascripts/search/store/state.js @@ -14,5 +14,11 @@ const createState = ({ query, navigation }) => ({ }, sidebarDirty: false, navigation, + aggregations: { + error: false, + fetching: false, + data: [], + }, }); + export default createState; diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue index 0629bea3239..da6039f4758 100644 --- a/app/assets/javascripts/search/topbar/components/app.vue +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -1,7 +1,6 @@ <script> import { GlSearchBoxByClick, GlButton } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; import MarkdownDrawer from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue'; @@ -31,7 +30,6 @@ export default { ProjectFilter, MarkdownDrawer, }, - mixins: [glFeatureFlagsMixin()], props: { groupInitialJson: { type: Object, @@ -70,9 +68,6 @@ export default { showSyntaxOptions() { return this.elasticsearchEnabled && this.isDefaultBranch; }, - hasVerticalNav() { - return this.glFeatures.searchPageVerticalNav; - }, isDefaultBranch() { return !this.query.repository_ref || this.query.repository_ref === this.defaultBranchName; }, @@ -130,6 +125,6 @@ export default { <project-filter :initial-data="projectInitialJson" /> </div> </div> - <hr v-if="hasVerticalNav" class="gl-mt-5 gl-mb-0 gl-border-gray-100" /> + <hr class="gl-mt-5 gl-mb-0 gl-border-gray-100" /> </section> </template> diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index 7828efc358a..3ebd21609a6 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -4,6 +4,7 @@ import { __, s__ } from '~/locale'; 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 SafeHtml from '~/vue_shared/directives/safe_html'; 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'; @@ -51,6 +52,7 @@ export default { UserCalloutDismisser, TrainingProviderList, }, + directives: { SafeHtml }, inject: ['projectFullPath', 'vulnerabilityTrainingDocsPath'], props: { augmentedSecurityFeatures: { @@ -143,7 +145,7 @@ export default { variant="danger" @dismiss="dismissAlert" > - {{ errorMessage }} + <span v-safe-html="errorMessage"></span> </gl-alert> <local-storage-sync v-model="autoDevopsEnabledAlertDismissedProjects" diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 77216408c39..c87dcef6a93 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -9,7 +9,6 @@ import { REPORT_TYPE_SECRET_DETECTION, REPORT_TYPE_DEPENDENCY_SCANNING, REPORT_TYPE_CONTAINER_SCANNING, - REPORT_TYPE_CLUSTER_IMAGE_SCANNING, REPORT_TYPE_COVERAGE_FUZZING, REPORT_TYPE_CORPUS_MANAGEMENT, REPORT_TYPE_API_FUZZING, @@ -105,18 +104,6 @@ export const CONTAINER_SCANNING_CONFIG_HELP_PATH = helpPagePath( { anchor: 'configuration' }, ); -export const CLUSTER_IMAGE_SCANNING_NAME = s__('ciReport|Cluster Image Scanning'); -export const CLUSTER_IMAGE_SCANNING_DESCRIPTION = __( - 'Check your Kubernetes cluster images for known vulnerabilities.', -); -export const CLUSTER_IMAGE_SCANNING_HELP_PATH = helpPagePath( - 'user/application_security/cluster_image_scanning/index', -); -export const CLUSTER_IMAGE_SCANNING_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/cluster_image_scanning/index', - { anchor: 'configuration' }, -); - export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing'); export const COVERAGE_FUZZING_DESCRIPTION = __( 'Find bugs in your code with coverage-guided fuzzing.', @@ -153,7 +140,6 @@ export const SCANNER_NAMES_MAP = { DAST: DAST_SHORT_NAME, API_FUZZING: API_FUZZING_NAME, CONTAINER_SCANNING: CONTAINER_SCANNING_NAME, - CLUSTER_IMAGE_SCANNING: CLUSTER_IMAGE_SCANNING_NAME, COVERAGE_FUZZING: COVERAGE_FUZZING_NAME, SECRET_DETECTION: SECRET_DETECTION_NAME, DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME, @@ -213,13 +199,6 @@ export const securityFeatures = [ type: REPORT_TYPE_CONTAINER_SCANNING, }, { - name: CLUSTER_IMAGE_SCANNING_NAME, - description: CLUSTER_IMAGE_SCANNING_DESCRIPTION, - helpPath: CLUSTER_IMAGE_SCANNING_HELP_PATH, - configurationHelpPath: CLUSTER_IMAGE_SCANNING_CONFIG_HELP_PATH, - type: REPORT_TYPE_CLUSTER_IMAGE_SCANNING, - }, - { name: SECRET_DETECTION_NAME, description: SECRET_DETECTION_DESCRIPTION, helpPath: SECRET_DETECTION_HELP_PATH, diff --git a/app/assets/javascripts/service_ping_consent.js b/app/assets/javascripts/service_ping_consent.js index f2c3f28cefa..654263ba27b 100644 --- a/app/assets/javascripts/service_ping_consent.js +++ b/app/assets/javascripts/service_ping_consent.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { createAlert, hideFlash } from './flash'; +import { createAlert } from './flash'; import axios from './lib/utils/axios_utils'; import { parseBoolean } from './lib/utils/common_utils'; import { __ } from './locale'; @@ -18,7 +18,7 @@ export default () => { }; const hideConsentMessage = () => - hideFlash(document.querySelector('.service-ping-consent-message')); + document.querySelector('.service-ping-consent-message .js-close')?.click(); axios .put(url, data) diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue index 240e12ee597..323f6f23df6 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue @@ -1,6 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; export default { @@ -19,7 +19,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, computed: { 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 d17c8a123d5..73cd0044c16 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -1,6 +1,6 @@ <script> import { GlTooltipDirective, GlLink } from '@gitlab/ui'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { isUserBusy } from '~/set_status_modal/utils'; @@ -67,7 +67,7 @@ export default { }, issuableType: { type: String, - default: 'issue', + default: TYPE_ISSUE, required: false, }, }, diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index cf07752a0b8..5cdebee04ad 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -1,4 +1,5 @@ <script> +import { TYPE_ISSUE } from '~/issues/constants'; import CollapsedAssigneeList from './collapsed_assignee_list.vue'; import UncollapsedAssigneeList from './uncollapsed_assignee_list.vue'; @@ -22,7 +23,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, computed: { diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue index 46bda26c327..fab856883cc 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue @@ -1,4 +1,5 @@ <script> +import { TYPE_ISSUE } from '~/issues/constants'; import AssigneeAvatar from './assignee_avatar.vue'; import UserNameWithStatus from './user_name_with_status.vue'; @@ -15,7 +16,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, computed: { 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 f894ef0c42d..d2f0ceb19c9 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import { isUserBusy } from '~/set_status_modal/utils'; import CollapsedAssignee from './collapsed_assignee.vue'; @@ -41,7 +42,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, computed: { diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index fd51cd5bb16..ed29ccb3447 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -1,5 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { n__ } from '~/locale'; import UncollapsedAssigneeList from './uncollapsed_assignee_list.vue'; @@ -16,7 +17,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, signedIn: { type: Boolean, diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 7979f450fdd..caf3bb2f798 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -1,6 +1,7 @@ <script> import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/flash'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../../event_hub'; @@ -34,7 +35,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, issuableIid: { type: String, @@ -63,7 +64,7 @@ export default { computed: { shouldEnableRealtime() { // Note: Realtime is only available on issues right now, future support for MR wil be built later. - return this.issuableType === 'issue'; + return this.issuableType === TYPE_ISSUE; }, queryVariables() { return { diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index d6c679f2f07..8893e90b1e5 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -2,7 +2,7 @@ import { GlDropdownItem } from '@gitlab/ui'; import Vue from 'vue'; import { createAlert } from '~/flash'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { __, n__ } from '~/locale'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -58,9 +58,9 @@ export default { issuableType: { type: String, required: false, - default: IssuableType.Issue, + default: TYPE_ISSUE, validator(value) { - return [IssuableType.Issue, IssuableType.MergeRequest, IssuableType.Alert].includes(value); + return [TYPE_ISSUE, IssuableType.MergeRequest, IssuableType.Alert].includes(value); }, }, issuableId: { @@ -118,7 +118,7 @@ export default { computed: { shouldEnableRealtime() { // Note: Realtime is only available on issues right now, future support for MR wil be built later. - return this.issuableType === IssuableType.Issue; + return this.issuableType === TYPE_ISSUE; }, queryVariables() { return { diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue index 29298ef7627..ddbd8866680 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -1,6 +1,6 @@ <script> import { GlAvatarLabeled, GlIcon } from '@gitlab/ui'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { s__, sprintf } from '~/locale'; const AVAILABILITY_STATUS = { @@ -21,7 +21,7 @@ export default { issuableType: { type: String, required: false, - default: IssuableType.Issue, + default: TYPE_ISSUE, }, }, computed: { 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 d83ae782e26..71f349bb87e 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -1,6 +1,6 @@ <script> import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import AssigneeAvatarLink from './assignee_avatar_link.vue'; import UserNameWithStatus from './user_name_with_status.vue'; @@ -21,7 +21,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, data() { 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 6afaee91d7a..1eeb725d5c9 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlAlert, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; -import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { TYPE_EPIC, WorkspaceType } from '~/issues/constants'; import { confidentialityInfoText } from '~/vue_shared/constants'; export default { @@ -25,7 +25,7 @@ export default { computed: { confidentialBodyText() { return confidentialityInfoText( - this.issuableType === IssuableType.Epic ? WorkspaceType.group : WorkspaceType.project, + this.issuableType === TYPE_EPIC ? WorkspaceType.group : WorkspaceType.project, this.issuableType, ); }, 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 dbedfe57325..f7526bcff3d 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -1,7 +1,7 @@ <script> import { GlSprintf, GlButton } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { IssuableType } from '~/issues/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import { confidentialityQueries } from '../../constants'; @@ -53,11 +53,14 @@ export default { ? this.$options.i18n.confidentialityOffWarning : this.$options.i18n.confidentialityOnWarning; }, + isIssue() { + return this.issuableType === TYPE_ISSUE; + }, context() { - return this.issuableType === IssuableType.Issue ? __('project') : __('group'); + return this.isIssue ? __('project') : __('group'); }, workspacePath() { - return this.issuableType === IssuableType.Issue + return this.isIssue ? { projectPath: this.fullPath, } @@ -66,7 +69,7 @@ export default { }; }, permissions() { - return this.issuableType === IssuableType.Issue + return this.isIssue ? __('at least the Reporter role, the author, and assignees') : __('at least the Reporter role'); }, 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 0660e4f58e4..c9ecaf4102f 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue +++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue @@ -3,7 +3,7 @@ import { GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; import { createAlert } from '~/flash'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE } from '~/graphql_shared/constants'; import getIssueCrmContactsQuery from '../../queries/get_issue_crm_contacts.query.graphql'; import issueCrmContactsSubscription from '../../queries/issue_crm_contacts.subscription.graphql'; @@ -65,7 +65,7 @@ export default { return this.contacts?.length; }, queryVariables() { - return { id: convertToGraphQLId(TYPE_ISSUE, this.issueId) }; + return { id: convertToGraphQLId(TYPENAME_ISSUE, this.issueId) }; }, contactsLabel() { return sprintf(n__('%{count} contact', '%{count} contacts', this.contactCount), { diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index eb48732f558..77be8022ec0 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { IssuableType } from '~/issues/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; import { dateFields, dateTypes, dueDateQueries, startDateQueries, Tracking } from '../../constants'; @@ -142,7 +142,7 @@ export default { return dateInWords(this.parsedDate, true); }, workspacePath() { - return this.issuableType === IssuableType.Issue + return this.issuableType === TYPE_ISSUE ? { projectPath: this.fullPath, } @@ -235,7 +235,7 @@ export default { help: __('Help'), learnMore: __('Learn more'), }, - dateHelpUrl: '/help/user/group/epics/index.md#start-date-and-due-date', + dateHelpUrl: '/help/user/group/epics/manage_epics.md#start-and-due-date-inheritance', }; </script> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql index 2904857270e..d7456a71aff 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql @@ -1,9 +1,9 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" -query issueLabels($fullPath: ID!, $iid: String) { +query issueLabels($fullPath: ID!, $iid: String, $types: [IssueType!]) { workspace: project(fullPath: $fullPath) { id - issuable: issue(iid: $iid) { + issuable: issue(iid: $iid, types: $types) { id labels { nodes { diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql new file mode 100644 index 00000000000..9ff7ce64d3b --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql @@ -0,0 +1,20 @@ +#import "~/graphql_shared/fragments/author.fragment.graphql" +#import "~/graphql_shared/fragments/label.fragment.graphql" + +mutation updateTestCaseLabels($input: UpdateIssueInput!) { + updateIssuableLabels: updateIssue(input: $input) { + issuable: issue { + id + updatedAt + updatedBy { + ...Author + } + labels { + nodes { + ...Label + } + } + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue index b7b4bbac661..bf916e26a15 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue @@ -4,7 +4,7 @@ import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labe import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/flash'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; import { issuableLabelsQueries } from '../../../constants'; @@ -161,10 +161,16 @@ export default { return !isDropdownVariantSidebar(this.variant); }, variables() { - return { + const queryVariables = { iid: this.iid, fullPath: this.fullPath, }; + + if (this.issuableType === IssuableType.TestCase) { + queryVariables.types = ['TEST_CASE']; + } + + return queryVariables; }, update(data) { return data.workspace?.issuable; @@ -255,14 +261,15 @@ export default { }; switch (this.issuableType) { - case IssuableType.Issue: + case TYPE_ISSUE: + case IssuableType.TestCase: return updateVariables; case IssuableType.MergeRequest: return { ...updateVariables, operationMode: MutationOperationMode.Replace, }; - case IssuableType.Epic: + case TYPE_EPIC: return { iid: currentIid, groupPath: this.fullPath, @@ -311,7 +318,8 @@ export default { }; switch (this.issuableType) { - case IssuableType.Issue: + case TYPE_ISSUE: + case IssuableType.TestCase: return { ...removeVariables, removeLabelIds: [labelId], @@ -322,7 +330,7 @@ export default { labelIds: [labelId], operationMode: MutationOperationMode.Remove, }; - case IssuableType.Epic: + case TYPE_EPIC: return { iid: this.iid, removeLabelIds: [getIdFromGraphQLId(labelId)], 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 cdce6617591..9d8f1304911 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -1,6 +1,7 @@ <script> import { GlIcon, GlTooltipDirective, GlOutsideDirective as Outside } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { createAlert } from '~/flash'; @@ -9,7 +10,6 @@ import eventHub from '../../event_hub'; import EditForm from './edit_form.vue'; export default { - issue: 'issue', locked: { icon: 'lock', class: 'value', @@ -49,7 +49,7 @@ export default { return this.getNoteableData.targetType === 'merge_request' && this.glFeatures.movedMrSidebar; }, issuableDisplayName() { - const isInIssuePage = this.getNoteableData.targetType === this.$options.issue; + const isInIssuePage = this.getNoteableData.targetType === TYPE_ISSUE; return isInIssuePage ? __('issue') : __('merge request'); }, isLocked() { diff --git a/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue index 1fff089eab4..8072154cd28 100644 --- a/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue @@ -1,8 +1,8 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; -import { TYPE_MILESTONE } from '~/graphql_shared/constants'; +import { TYPENAME_MILESTONE } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE, WorkspaceType } from '~/issues/constants'; import { __ } from '~/locale'; import { IssuableAttributeType } from '../../constants'; import SidebarDropdown from '../sidebar_dropdown.vue'; @@ -37,7 +37,7 @@ export default { type: String, required: true, validator(value) { - return [IssuableType.Issue, IssuableType.MergeRequest].includes(value); + return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value); }, }, inputName: { @@ -71,7 +71,10 @@ export default { data() { return { milestone: this.milestoneId - ? { id: convertToGraphQLId(TYPE_MILESTONE, this.milestoneId), title: this.milestoneTitle } + ? { + id: convertToGraphQLId(TYPENAME_MILESTONE, this.milestoneId), + title: this.milestoneTitle, + } : placeholderMilestone, }; }, diff --git a/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue index 02323e5a0c6..9f64ddc8721 100644 --- a/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue @@ -206,7 +206,7 @@ export default { category="primary" variant="confirm" :disabled="!Boolean(selectedProject)" - class="gl-text-center! issuable-move-button" + class="gl-w-full issuable-move-button" @click="handleMoveClick" >{{ __('Move') }}</gl-button > diff --git a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue new file mode 100644 index 00000000000..e1259fad6a7 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue @@ -0,0 +1,71 @@ +<script> +import ProjectSelect from '~/sidebar/components/move/issuable_move_dropdown.vue'; +import { __ } from '~/locale'; +import { createAlert } from '~/flash'; +import { visitUrl } from '~/lib/utils/url_utility'; +import moveIssueMutation from '../../queries/move_issue.mutation.graphql'; + +export default { + name: 'MoveIssueButton', + components: { ProjectSelect }, + inject: ['projectsAutocompleteEndpoint', 'projectFullPath', 'issueIid'], + + i18n: { + title: __('Move issue'), + titleInProgress: __('Moving issue'), + moveErrorMessage: __('An error occurred while moving the issue.'), + }, + data() { + return { + moveInProgress: false, + }; + }, + computed: { + dropdownButtonTitle() { + return this.moveInProgress ? this.$options.i18n.titleInProgress : this.$options.i18n.title; + }, + }, + methods: { + moveIssue(targetProject) { + this.moveInProgress = true; + return this.$apollo + .mutate({ + mutation: moveIssueMutation, + variables: { + moveIssueInput: { + projectPath: this.projectFullPath, + iid: this.issueIid, + targetProjectPath: targetProject.full_path, + }, + }, + }) + .then(({ data = {} }) => { + if (!data.issueMove) return; + + const { errors } = data.issueMove; + if (errors?.length > 0) { + throw new Error(`Error moving the issue. Error message: ${errors[0].message}`); + } + visitUrl(data.issueMove?.issue.webUrl); + }) + .catch((error) => { + this.moveInProgress = false; + createAlert({ + message: this.$options.i18n.moveErrorMessage, + captureError: true, + error, + }); + }); + }, + }, +}; +</script> +<template> + <project-select + :projects-fetch-path="projectsAutocompleteEndpoint" + :dropdown-button-title="dropdownButtonTitle" + :dropdown-header-title="$options.i18n.title" + :move-in-progress="moveInProgress" + @move-issuable="moveIssue" + /> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue index f69c027e201..56ac4c39e84 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue @@ -2,6 +2,7 @@ // NOTE! For the first iteration, we are simply copying the implementation of Assignees // It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import ReviewerAvatar from './reviewer_avatar.vue'; @@ -34,7 +35,7 @@ export default { }, issuableType: { type: String, - default: 'issue', + default: TYPE_ISSUE, required: false, }, }, diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue index 7af8dcb4e3e..bd1d9fbff0c 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -1,6 +1,7 @@ <script> // NOTE! For the first iteration, we are simply copying the implementation of Assignees // It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { TYPE_ISSUE } from '~/issues/constants'; import CollapsedReviewerList from './collapsed_reviewer_list.vue'; import UncollapsedReviewerList from './uncollapsed_reviewer_list.vue'; @@ -28,7 +29,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, computed: { diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue index faa36f3d8d2..8dd58d33ecf 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -4,8 +4,8 @@ import Vue from 'vue'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/flash'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import eventHub from '../../event_hub'; import getMergeRequestReviewersQuery from '../../queries/get_merge_request_reviewers.query.graphql'; @@ -26,7 +26,6 @@ export default { ReviewerTitle, Reviewers, }, - mixins: [glFeatureFlagsMixin()], props: { mediator: { type: Object, @@ -39,7 +38,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, issuableIid: { type: String, @@ -78,7 +77,7 @@ export default { }; }, skip() { - return !this.issuable?.id || !this.isRealtimeEnabled; + return !this.issuable?.id; }, updateQuery( _, @@ -119,9 +118,6 @@ export default { canUpdate() { return this.issuable.userPermissions?.adminMergeRequest || false; }, - isRealtimeEnabled() { - return this.glFeatures.realtimeReviewers; - }, }, created() { this.store = new Store(); 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 217ca2e2548..a3710d9534e 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf, s__ } from '~/locale'; import ReviewerAvatarLink from './reviewer_avatar_link.vue'; @@ -30,7 +31,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, data() { diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue deleted file mode 100644 index 5b624c17b0c..00000000000 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ /dev/null @@ -1,195 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownItem, - GlLoadingIcon, - GlTooltip, - GlSprintf, - GlButton, -} from '@gitlab/ui'; -import { createAlert } from '~/flash'; -import updateIssuableSeverity from '../../queries/update_issuable_severity.mutation.graphql'; -import { INCIDENT_SEVERITY, ISSUABLE_TYPES, SEVERITY_I18N as I18N } from '../../constants'; -import SeverityToken from './severity.vue'; - -export default { - i18n: I18N, - components: { - GlLoadingIcon, - GlTooltip, - GlSprintf, - GlDropdown, - GlDropdownItem, - GlButton, - SeverityToken, - }, - inject: ['canUpdate'], - props: { - projectPath: { - type: String, - required: true, - }, - iid: { - type: String, - required: true, - }, - initialSeverity: { - type: String, - required: false, - default: INCIDENT_SEVERITY.UNKNOWN.value, - }, - issuableType: { - type: String, - required: false, - default: ISSUABLE_TYPES.INCIDENT, - validator: (value) => { - // currently severity is supported only for incidents, but this list might be extended - return [ISSUABLE_TYPES.INCIDENT].includes(value); - }, - }, - }, - data() { - return { - isDropdownShowing: false, - isUpdating: false, - severity: this.initialSeverity, - }; - }, - computed: { - severitiesList() { - switch (this.issuableType) { - case ISSUABLE_TYPES.INCIDENT: - return Object.values(INCIDENT_SEVERITY); - default: - return []; - } - }, - dropdownClass() { - return this.isDropdownShowing ? 'show' : 'gl-display-none'; - }, - selectedItem() { - return this.severitiesList.find((severity) => severity.value === this.severity); - }, - }, - mounted() { - document.addEventListener('click', this.handleOffClick); - }, - beforeDestroy() { - document.removeEventListener('click', this.handleOffClick); - }, - methods: { - handleOffClick(event) { - if (!this.isDropdownShowing) { - return; - } - - if (!this.$refs.sidebarSeverity.contains(event.target)) { - this.hideDropdown(); - } - }, - hideDropdown() { - this.isDropdownShowing = false; - const event = new Event('hidden.gl.dropdown'); - this.$el.dispatchEvent(event); - }, - toggleFormDropdown() { - this.isDropdownShowing = !this.isDropdownShowing; - }, - updateSeverity(value) { - this.hideDropdown(); - this.isUpdating = true; - this.$apollo - .mutate({ - mutation: updateIssuableSeverity, - variables: { - iid: this.iid, - severity: value, - projectPath: this.projectPath, - }, - }) - .then((resp) => { - const { - data: { - issueSetSeverity: { - errors = [], - issue: { severity }, - }, - }, - } = resp; - - if (errors[0]) { - throw errors[0]; - } - this.severity = severity; - }) - .catch(() => - createAlert({ - message: `${this.$options.i18n.UPDATE_SEVERITY_ERROR} ${this.$options.i18n.TRY_AGAIN}`, - }), - ) - .finally(() => { - this.isUpdating = false; - }); - }, - }, -}; -</script> - -<template> - <div ref="sidebarSeverity" class="block"> - <div ref="severity" class="sidebar-collapsed-icon" @click="toggleFormDropdown"> - <severity-token :severity="selectedItem" :icon-size="14" :icon-only="true" /> - <gl-tooltip :target="() => $refs.severity" boundary="viewport" placement="left"> - <gl-sprintf :message="$options.i18n.SEVERITY_VALUE"> - <template #severity> - {{ selectedItem.label }} - </template> - </gl-sprintf> - </gl-tooltip> - </div> - - <div class="hide-collapsed"> - <div - class="gl-display-flex gl-align-items-center gl-line-height-20 gl-text-gray-900 gl-font-weight-bold" - > - {{ $options.i18n.SEVERITY }} - <gl-button - v-if="canUpdate" - category="tertiary" - size="small" - class="gl-ml-auto hide-collapsed gl-mr-n2" - data-testid="editButton" - @click="toggleFormDropdown" - @keydown.esc="hideDropdown" - > - {{ $options.i18n.EDIT }} - </gl-button> - </div> - - <gl-dropdown - class="gl-mt-3" - :class="dropdownClass" - block - :header-text="__('Assign severity')" - :text="selectedItem.label" - toggle-class="dropdown-menu-toggle gl-mb-2" - @keydown.esc.native="hideDropdown" - > - <gl-dropdown-item - v-for="option in severitiesList" - :key="option.value" - data-testid="severityDropdownItem" - is-check-item - :is-checked="option.value === severity" - @click="updateSeverity(option.value)" - > - <severity-token :severity="option" /> - </gl-dropdown-item> - </gl-dropdown> - - <gl-loading-icon v-if="isUpdating" size="sm" :inline="true" /> - - <severity-token v-else-if="!isDropdownShowing" :severity="selectedItem" /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue new file mode 100644 index 00000000000..ecb9a2809a0 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue @@ -0,0 +1,154 @@ +<script> +import { GlDropdown, GlDropdownItem, GlTooltip, GlSprintf } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import updateIssuableSeverity from '../../queries/update_issuable_severity.mutation.graphql'; +import { INCIDENT_SEVERITY, ISSUABLE_TYPES, SEVERITY_I18N as I18N } from '../../constants'; +import SeverityToken from './severity.vue'; + +export default { + i18n: I18N, + components: { + GlTooltip, + GlSprintf, + GlDropdown, + GlDropdownItem, + SeverityToken, + SidebarEditableItem, + }, + inject: ['canUpdate'], + props: { + projectPath: { + type: String, + required: true, + }, + iid: { + type: String, + required: true, + }, + initialSeverity: { + type: String, + required: false, + default: INCIDENT_SEVERITY.UNKNOWN.value, + }, + issuableType: { + type: String, + required: false, + default: ISSUABLE_TYPES.INCIDENT, + validator: (value) => { + // currently severity is supported only for incidents, but this list might be extended + return [ISSUABLE_TYPES.INCIDENT].includes(value); + }, + }, + }, + data() { + return { + isUpdating: false, + severity: this.initialSeverity, + }; + }, + computed: { + severitiesList() { + switch (this.issuableType) { + case ISSUABLE_TYPES.INCIDENT: + return Object.values(INCIDENT_SEVERITY); + default: + return []; + } + }, + selectedItem() { + return this.severitiesList.find((severity) => severity.value === this.severity); + }, + }, + methods: { + updateSeverity(value) { + this.$refs.toggle.collapse(); + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: updateIssuableSeverity, + variables: { + iid: this.iid, + severity: value, + projectPath: this.projectPath, + }, + }) + .then((resp) => { + const { + data: { + issueSetSeverity: { + errors = [], + issue: { severity }, + }, + }, + } = resp; + + if (errors[0]) { + throw errors[0]; + } + this.severity = severity; + }) + .catch(() => + createAlert({ + message: `${this.$options.i18n.UPDATE_SEVERITY_ERROR} ${this.$options.i18n.TRY_AGAIN}`, + }), + ) + .finally(() => { + this.isUpdating = false; + }); + }, + showDropdown() { + this.$refs.dropdown.show(); + }, + }, +}; +</script> + +<template> + <div ref="sidebarSeverity" class="block"> + <sidebar-editable-item + ref="toggle" + :loading="isUpdating" + :title="$options.i18n.SEVERITY" + :can-edit="canUpdate" + @open="showDropdown" + > + <template #collapsed> + <div ref="severity" class="sidebar-collapsed-icon"> + <severity-token :severity="selectedItem" :icon-size="14" :icon-only="true" /> + <gl-tooltip :target="() => $refs.severity" boundary="viewport" placement="left"> + <gl-sprintf :message="$options.i18n.SEVERITY_VALUE"> + <template #severity> + {{ selectedItem.label }} + </template> + </gl-sprintf> + </gl-tooltip> + </div> + <div class="hide-collapsed"> + <severity-token :severity="selectedItem" /> + </div> + </template> + + <template #default> + <gl-dropdown + ref="dropdown" + class="gl-mt-3" + block + :header-text="__('Assign severity')" + :text="selectedItem.label" + > + <gl-dropdown-item + v-for="option in severitiesList" + :key="option.value" + data-testid="severityDropdownItem" + is-check-item + :is-checked="option.value === severity" + @click="updateSeverity(option.value)" + > + <severity-token :severity="option" /> + </gl-dropdown-item> + </gl-dropdown> + </template> + </sidebar-editable-item> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue index 26e2bc96f54..d68e4974ea4 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue @@ -8,7 +8,7 @@ import { GlSearchBoxByType, } from '@gitlab/ui'; import { kebabCase, snakeCase } from 'lodash'; -import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { IssuableType, TYPE_EPIC, TYPE_ISSUE, WorkspaceType } from '~/issues/constants'; import { __ } from '~/locale'; import { defaultEpicSort, @@ -70,7 +70,7 @@ export default { type: String, required: true, validator(value) { - return [IssuableType.Issue, IssuableType.MergeRequest].includes(value); + return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value); }, }, workspaceType: { @@ -155,7 +155,7 @@ export default { }, isEpic() { // MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311 - return this.issuableAttribute === IssuableType.Epic; + return this.issuableAttribute === TYPE_EPIC; }, issuableAttributeQuery() { return this.issuableAttributesQueries[this.issuableAttribute]; diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 35667495ace..5df65c4aaaf 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -3,7 +3,7 @@ import { GlButton, GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab import { kebabCase, snakeCase } from 'lodash'; import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants'; import { timeFor } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -71,7 +71,7 @@ export default { type: String, required: true, validator(value) { - return [IssuableType.Issue, IssuableType.MergeRequest].includes(value); + return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value); }, }, icon: { @@ -153,7 +153,7 @@ export default { }, isEpic() { // MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311 - return this.issuableAttribute === IssuableType.Epic; + return this.issuableAttribute === TYPE_EPIC; }, formatIssuableAttribute() { return { @@ -188,7 +188,7 @@ export default { fullPath: this.workspacePath, attributeId: this.issuableAttribute === IssuableAttributeType.Milestone && - this.issuableType === IssuableType.Issue + this.issuableType === TYPE_ISSUE ? getIdFromGraphQLId(id) : id, iid: this.iid, 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 0fba1cb5e4e..cbe839d1112 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -1,7 +1,7 @@ <script> import { GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_EPIC } from '~/issues/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -105,7 +105,7 @@ export default { return ICON_ON; }, parentIsGroup() { - return this.issuableType === IssuableType.Epic; + return this.issuableType === TYPE_EPIC; }, subscribeDisabledDescription() { return sprintf(__('Disabled by %{parent} owner'), { diff --git a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue index ec8e1ee9952..964da3b6138 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue @@ -10,8 +10,9 @@ import { GlSprintf, } from '@gitlab/ui'; import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/issues/constants'; import { formatDate } from '~/lib/utils/datetime_utility'; -import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { joinPaths } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import createTimelogMutation from '../../queries/create_timelog.mutation.graphql'; @@ -127,10 +128,10 @@ export default { }); }, isIssue() { - return this.issuableType === 'issue'; + return this.issuableType === TYPE_ISSUE; }, getGraphQLEntityType() { - return this.isIssue() ? TYPE_ISSUE : TYPE_MERGE_REQUEST; + return this.isIssue() ? TYPENAME_ISSUE : TYPENAME_MERGE_REQUEST; }, updateSpentAtDate(val) { this.spentAt = val; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue index 6f4ced06ddf..cffbb6466f2 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -1,8 +1,9 @@ <script> import { GlLoadingIcon, GlTableLite, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/issues/constants'; import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; import { __, s__ } from '~/locale'; import { timelogQueries } from '../../constants'; @@ -61,7 +62,7 @@ export default { return this.removingIds.includes(timelogId); }, isIssue() { - return this.issuableType === 'issue'; + return this.issuableType === TYPE_ISSUE; }, getQueryVariables() { return { @@ -69,7 +70,7 @@ export default { }; }, getGraphQLEntityType() { - return this.isIssue() ? TYPE_ISSUE : TYPE_MERGE_REQUEST; + return this.isIssue() ? TYPENAME_ISSUE : TYPENAME_MERGE_REQUEST; }, extractTimelogs(data) { const timelogs = data?.issuable?.timelogs?.nodes || []; 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 b32836dc87d..c645b1649d2 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -8,7 +8,7 @@ import { GlLoadingIcon, GlTooltipDirective, } from '@gitlab/ui'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__, __ } from '~/locale'; @@ -173,10 +173,7 @@ export default { return Boolean(this.showHelp); }, isTimeReportSupported() { - return ( - [IssuableType.Issue, IssuableType.MergeRequest].includes(this.issuableType) && - this.issuableId - ); + return [TYPE_ISSUE, IssuableType.MergeRequest].includes(this.issuableType) && this.issuableId; }, timeTrackingIconTitle() { return this.showHelpState ? '' : HOW_TO_TRACK_TIME; diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 825a89daf58..14491226b15 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -3,8 +3,9 @@ import { s__, __, sprintf } from '~/locale'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql'; -import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { IssuableType, TYPE_EPIC, TYPE_ISSUE, WorkspaceType } from '~/issues/constants'; import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; +import updateTestCaseLabelsMutation from './components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql'; import epicLabelsQuery from './components/labels/labels_select_widget/graphql/epic_labels.query.graphql'; import updateEpicLabelsMutation from './components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; import groupLabelsQuery from './components/labels/labels_select_widget/graphql/group_labels.query.graphql'; @@ -63,7 +64,7 @@ export const defaultEpicSort = 'TITLE_ASC'; export const epicIidPattern = /^&(?<iid>\d+)$/; export const assigneesQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: getIssueAssignees, subscription: issuableAssigneesSubscription, mutation: updateIssueAssigneesMutation, @@ -79,13 +80,13 @@ export const assigneesQueries = { }; export const participantsQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueParticipantsQuery, }, [IssuableType.MergeRequest]: { query: getMergeRequestParticipants, }, - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicParticipantsQuery, }, [IssuableType.Alert]: { @@ -95,7 +96,7 @@ export const participantsQueries = { }; export const userSearchQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: userSearchQuery, }, [IssuableType.MergeRequest]: { @@ -104,24 +105,24 @@ export const userSearchQueries = { }; export const confidentialityQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueConfidentialQuery, mutation: updateIssueConfidentialMutation, }, - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicConfidentialQuery, mutation: updateEpicConfidentialMutation, }, }; export const referenceQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueReferenceQuery, }, [IssuableType.MergeRequest]: { query: mergeRequestReferenceQuery, }, - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicReferenceQuery, }, }; @@ -136,7 +137,7 @@ export const workspaceLabelsQueries = { }; export const issuableLabelsQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { issuableQuery: issueLabelsQuery, mutation: updateIssueLabelsMutation, mutationName: 'updateIssue', @@ -146,11 +147,16 @@ export const issuableLabelsQueries = { mutation: updateMergeRequestLabelsMutation, mutationName: 'mergeRequestSetLabels', }, - [IssuableType.Epic]: { + [TYPE_EPIC]: { issuableQuery: epicLabelsQuery, mutation: updateEpicLabelsMutation, mutationName: 'updateEpic', }, + [IssuableType.TestCase]: { + issuableQuery: issueLabelsQuery, + mutation: updateTestCaseLabelsMutation, + mutationName: 'updateTestCaseLabels', + }, }; export const dateTypes = { @@ -172,11 +178,11 @@ export const dateFields = { }; export const subscribedQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueSubscribedQuery, mutation: updateIssueSubscriptionMutation, }, - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicSubscribedQuery, mutation: updateEpicSubscriptionMutation, }, @@ -192,7 +198,7 @@ export const Tracking = { }; export const timeTrackingQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueTimeTrackingQuery, }, [IssuableType.MergeRequest]: { @@ -201,25 +207,25 @@ export const timeTrackingQueries = { }; export const dueDateQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueDueDateQuery, mutation: updateIssueDueDateMutation, }, - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicDueDateQuery, mutation: updateEpicDueDateMutation, }, }; export const startDateQueries = { - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicStartDateQuery, mutation: updateEpicStartDateMutation, }, }; export const timelogQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: getIssueTimelogsQuery, }, [IssuableType.MergeRequest]: { @@ -230,7 +236,7 @@ export const timelogQueries = { export const noAttributeId = null; export const issuableMilestoneQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: projectIssueMilestoneQuery, mutation: projectIssueMilestoneMutation, }, @@ -241,7 +247,7 @@ export const issuableMilestoneQueries = { }; export const milestonesQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: { [WorkspaceType.group]: groupMilestonesQuery, [WorkspaceType.project]: projectMilestonesQuery, @@ -277,10 +283,10 @@ export const issuableAttributesQueries = { }; export const todoQueries = { - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicTodoQuery, }, - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueTodoQuery, }, [IssuableType.MergeRequest]: { diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js deleted file mode 100644 index 2cce27df598..00000000000 --- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js +++ /dev/null @@ -1,89 +0,0 @@ -import $ from 'jquery'; -import { escape } from 'lodash'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { createAlert } from '~/flash'; -import { __ } from '~/locale'; - -function isValidProjectId(id) { - return id > 0; -} - -class SidebarMoveIssue { - constructor(mediator, dropdownToggle, confirmButton) { - this.mediator = mediator; - - this.$dropdownToggle = $(dropdownToggle); - this.$confirmButton = $(confirmButton); - - this.onConfirmClickedWrapper = this.onConfirmClicked.bind(this); - } - - init() { - this.initDropdown(); - this.addEventListeners(); - } - - destroy() { - this.removeEventListeners(); - } - - initDropdown() { - initDeprecatedJQueryDropdown(this.$dropdownToggle, { - search: { - fields: ['name_with_namespace'], - }, - showMenuAbove: true, - selectable: true, - filterable: true, - filterRemote: true, - multiSelect: false, - // Keep the dropdown open after selecting an option - shouldPropagate: false, - data: (searchTerm, callback) => { - this.mediator - .fetchAutocompleteProjects(searchTerm) - .then(callback) - .catch(() => - createAlert({ - message: __('An error occurred while fetching projects autocomplete.'), - }), - ); - }, - renderRow: (project) => ` - <li> - <a href="#" class="js-move-issue-dropdown-item"> - ${escape(project.name_with_namespace)} - </a> - </li> - `, - clicked: (options) => { - const project = options.selectedObj; - const selectedProjectId = options.isMarking ? project.id : 0; - this.mediator.setMoveToProjectId(selectedProjectId); - - this.$confirmButton.prop('disabled', !isValidProjectId(selectedProjectId)); - }, - }); - } - - addEventListeners() { - this.$confirmButton.on('click', this.onConfirmClickedWrapper); - } - - removeEventListeners() { - this.$confirmButton.off('click', this.onConfirmClickedWrapper); - } - - onConfirmClicked() { - if (isValidProjectId(this.mediator.store.moveToProjectId)) { - this.$confirmButton.disable().addClass('is-loading'); - - this.mediator.moveIssue().catch(() => { - createAlert({ message: __('An error occurred while moving the issue.') }); - this.$confirmButton.enable().removeClass('is-loading'); - }); - } - } -} - -export default SidebarMoveIssue; diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index a308dc8d13c..fb024d818da 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -1,22 +1,22 @@ -import $ from 'jquery'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { gqlClient } from '~/issues/list/graphql'; import { - isInIssuePage, isInDesignPage, isInIncidentPage, + isInIssuePage, isInMRPage, parseBoolean, } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import { apolloProvider } from '~/graphql_shared/issuable_client'; import Translate from '~/vue_shared/translate'; +import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import CollapsedAssigneeList from './components/assignees/collapsed_assignee_list.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import SidebarAssigneesWidget from './components/assignees/sidebar_assignees_widget.vue'; @@ -34,7 +34,7 @@ import SidebarParticipantsWidget from './components/participants/sidebar_partici import SidebarReferenceWidget from './components/copy/sidebar_reference_widget.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarReviewersInputs from './components/reviewers/sidebar_reviewers_inputs.vue'; -import SidebarSeverity from './components/severity/sidebar_severity.vue'; +import SidebarSeverityWidget from './components/severity/sidebar_severity_widget.vue'; import SidebarDropdownWidget from './components/sidebar_dropdown_widget.vue'; import StatusDropdown from './components/status/status_dropdown.vue'; import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue'; @@ -43,8 +43,8 @@ import SidebarTimeTracking from './components/time_tracking/sidebar_time_trackin import SidebarTodoWidget from './components/todo_toggle/sidebar_todo_widget.vue'; import { IssuableAttributeType } from './constants'; import CrmContacts from './components/crm_contacts/crm_contacts.vue'; -import SidebarMoveIssue from './lib/sidebar_move_issue'; import trackShowInviteMemberLink from './track_invite_members'; +import MoveIssueButton from './components/move/move_issue_button.vue'; Vue.use(Translate); Vue.use(VueApollo); @@ -75,12 +75,12 @@ function mountSidebarTodoWidget() { fullPath: projectPath, issuableId: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? convertToGraphQLId(TYPE_ISSUE, id) - : convertToGraphQLId(TYPE_MERGE_REQUEST, id), + ? convertToGraphQLId(TYPENAME_ISSUE, id) + : convertToGraphQLId(TYPENAME_MERGE_REQUEST, id), issuableIid: iid, issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, }, }), @@ -124,7 +124,7 @@ function mountSidebarAssigneesDeprecated(mediator) { signedIn: Object.prototype.hasOwnProperty.call(el.dataset, 'signedIn'), issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, issuableId: id, assigneeAvailabilityStatus, @@ -142,7 +142,7 @@ function mountSidebarAssigneesWidget() { const { id, iid, fullPath, editable } = getSidebarOptions(); const isIssuablePage = isInIssuePage() || isInIncidentPage() || isInDesignPage(); - const issuableType = isIssuablePage ? IssuableType.Issue : IssuableType.MergeRequest; + const issuableType = isIssuablePage ? TYPE_ISSUE : IssuableType.MergeRequest; // eslint-disable-next-line no-new new Vue({ el, @@ -205,7 +205,7 @@ function mountSidebarReviewers(mediator) { projectPath: fullPath, field: el.dataset.field, issuableType: - isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, + isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : IssuableType.MergeRequest, }, }), }); @@ -276,7 +276,7 @@ function mountSidebarMilestoneWidget() { workspacePath: projectPath, iid: issueIid, issuableType: - isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, + isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : IssuableType.MergeRequest, issuableAttribute: IssuableAttributeType.Milestone, icon: 'clock', }, @@ -313,7 +313,7 @@ export function mountMilestoneDropdown() { attrWorkspacePath: fullPath, canAdminMilestone, inputName, - issuableType: isInIssuePage() ? IssuableType.Issue : IssuableType.MergeRequest, + issuableType: isInIssuePage() ? TYPE_ISSUE : IssuableType.MergeRequest, milestoneId, milestoneTitle, projectMilestonesPath, @@ -357,7 +357,7 @@ export function mountSidebarLabelsWidget() { variant: DropdownVariant.Sidebar, issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, workspaceType: 'project', attrWorkspacePath: el.dataset.projectPath, @@ -397,7 +397,7 @@ function mountSidebarConfidentialityWidget() { fullPath, issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, }, }), @@ -425,7 +425,7 @@ function mountSidebarDueDateWidget() { props: { iid: String(iid), fullPath, - issuableType: IssuableType.Issue, + issuableType: TYPE_ISSUE, }, }), }); @@ -453,7 +453,7 @@ function mountSidebarReferenceWidget() { props: { issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, }, }), @@ -505,7 +505,7 @@ function mountSidebarParticipantsWidget() { fullPath, issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, }, }), @@ -535,7 +535,7 @@ function mountSidebarSubscriptionsWidget() { fullPath, issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, }, }), @@ -576,8 +576,8 @@ function mountSidebarTimeTracking() { }); } -function mountSidebarSeverity() { - const el = document.querySelector('.js-sidebar-severity-root'); +function mountSidebarSeverityWidget() { + const el = document.querySelector('.js-sidebar-severity-widget-root'); if (!el) { return null; @@ -587,13 +587,13 @@ function mountSidebarSeverity() { return new Vue({ el, - name: 'SidebarSeverityRoot', + name: 'SidebarSeverityWidgetRoot', apolloProvider, provide: { canUpdate: editable, }, render: (createElement) => - createElement(SidebarSeverity, { + createElement(SidebarSeverityWidget, { props: { projectPath: fullPath, iid: String(iid), @@ -701,6 +701,95 @@ export function mountSubscriptionsDropdown() { }); } +export function mountMoveIssueButton() { + const el = document.querySelector('.js-sidebar-move-issue-block'); + + if (!el) { + return null; + } + + const { projectsAutocompleteEndpoint } = getSidebarOptions(); + const { projectFullPath, issueIid } = el.dataset; + + Vue.use(VueApollo); + + return new Vue({ + el, + name: 'MoveIssueDropdownRoot', + apolloProvider, + provide: { + projectsAutocompleteEndpoint, + projectFullPath, + issueIid, + }, + render: (createElement) => createElement(MoveIssueButton), + }); +} + +export function mountAssigneesDropdown() { + const el = document.querySelector('.js-assignee-dropdown'); + const assigneeIdsInput = document.querySelector('.js-assignee-ids-input'); + + if (!el || !assigneeIdsInput) { + return null; + } + + const { fullPath } = el.dataset; + const currentUser = { + id: gon?.current_user_id, + username: gon?.current_username, + name: gon?.current_user_fullname, + avatarUrl: gon?.current_user_avatar_url, + }; + + return new Vue({ + el, + apolloProvider, + data() { + return { + selectedUserName: '', + value: [], + }; + }, + methods: { + onSelectedUnassigned() { + assigneeIdsInput.value = 0; + this.value = []; + this.selectedUserName = __('Unassigned'); + }, + onSelected(selected) { + assigneeIdsInput.value = selected.map((user) => getIdFromGraphQLId(user.id)); + this.value = selected; + this.selectedUserName = selected.map((user) => user.name).join(', '); + }, + }, + render(h) { + const component = this; + + return h(UserSelect, { + props: { + text: component.selectedUserName || __('Select assignee'), + headerText: __('Assign to'), + fullPath, + currentUser, + value: component.value, + }, + on: { + input(selected) { + if (!selected.length) { + component.onSelectedUnassigned(); + return; + } + + component.onSelected(selected); + }, + }, + class: 'gl-w-full', + }); + }, + }); +} + const isAssigneesWidgetShown = (isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget; @@ -725,14 +814,9 @@ export function mountSidebar(mediator, store) { mountSidebarSubscriptionsWidget(); mountCopyEmailToClipboard(); mountSidebarTimeTracking(); - mountSidebarSeverity(); + mountSidebarSeverityWidget(); mountSidebarEscalationStatus(); - - new SidebarMoveIssue( - mediator, - $('.js-move-issue'), - $('.js-move-issue-confirmation-button'), - ).init(); + mountMoveIssueButton(); } export { getSidebarOptions }; diff --git a/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql b/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql index d350072425b..e3ed3c5089b 100644 --- a/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql @@ -1,5 +1,9 @@ mutation moveIssue($moveIssueInput: IssueMoveInput!) { issueMove(input: $moveIssueInput) { + issue { + id + webUrl + } errors } } diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index 00d3177b75a..af267f65502 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -1,4 +1,4 @@ -import { TYPE_USER } from '~/graphql_shared/constants'; +import { TYPENAME_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; @@ -54,7 +54,7 @@ export default class SidebarService { return gqClient.mutate({ mutation: reviewerRereviewMutation, variables: { - userId: convertToGraphQLId(TYPE_USER, `${userId}`), + userId: convertToGraphQLId(TYPENAME_USER, `${userId}`), projectPath: this.fullPath, iid: this.iid.toString(), }, diff --git a/app/assets/javascripts/snippet/snippet_show.js b/app/assets/javascripts/snippet/snippet_show.js index 6d0e4770e1c..277d43e43a4 100644 --- a/app/assets/javascripts/snippet/snippet_show.js +++ b/app/assets/javascripts/snippet/snippet_show.js @@ -3,11 +3,13 @@ import initDeprecatedNotes from '~/init_deprecated_notes'; import SnippetsAppFactory from '~/snippets'; import SnippetsShow from '~/snippets/components/show.vue'; import ZenMode from '~/zen_mode'; +import { initReportAbuse } from '~/projects/report_abuse'; SnippetsAppFactory(document.getElementById('js-snippet-view'), SnippetsShow); initDeprecatedNotes(); loadAwardsHandler(); +initReportAbuse(); // eslint-disable-next-line no-new new ZenMode(); diff --git a/app/assets/javascripts/super_sidebar/components/bottom_bar.vue b/app/assets/javascripts/super_sidebar/components/bottom_bar.vue deleted file mode 100644 index fea29458f45..00000000000 --- a/app/assets/javascripts/super_sidebar/components/bottom_bar.vue +++ /dev/null @@ -1,24 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { - GlIcon, - }, - i18n: { - help: __('Help'), - new: __('New'), - }, -}; -</script> - -<template> - <div class="bottom-links gl-p-3"> - <a href="#" class="gl-text-black-normal" - ><gl-icon name="question-o" class="gl-mr-3 gl-text-gray-300 gl-text-black-normal!" />{{ - $options.i18n.help - }}</a - > - </div> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue index d790e61ca31..62a1e5a6b20 100644 --- a/app/assets/javascripts/super_sidebar/components/counter.vue +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -40,9 +40,9 @@ export default { :is="component" :aria-label="ariaLabel" :href="href" - class="counter gl-relative gl-display-inline-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-black-normal gl-border gl-border-gray-a-08 gl-font-sm gl-font-weight-bold" + class="counter gl-display-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border gl-border-gray-a-08 gl-font-sm gl-hover-text-gray-900 gl-hover-text-decoration-none" > <gl-icon aria-hidden="true" :name="icon" /> - <span aria-hidden="true">{{ count }}</span> + <span v-if="count" aria-hidden="true" class="gl-ml-1">{{ count }}</span> </component> </template> diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue new file mode 100644 index 00000000000..e92a6cbf5f5 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue @@ -0,0 +1,38 @@ +<script> +import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlDisclosureDropdown, + GlTooltip, + }, + i18n: { + createNew: __('Create new...'), + }, + props: { + groups: { + type: Array, + required: true, + }, + }, + toggleId: 'create-menu-toggle', +}; +</script> + +<template> + <div> + <gl-disclosure-dropdown + category="tertiary" + icon="plus" + :items="groups" + no-caret + text-sr-only + :toggle-text="$options.i18n.createNew" + :toggle-id="$options.toggleId" + /> + <gl-tooltip :target="`#${$options.toggleId}`" placement="bottom" container="#super-sidebar"> + {{ $options.i18n.createNew }} + </gl-tooltip> + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue new file mode 100644 index 00000000000..8e7c7efa631 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/help_center.vue @@ -0,0 +1,178 @@ +<script> +import { GlBadge, GlButton, GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui'; +import GitlabVersionCheckBadge from '~/gitlab_version_check/components/gitlab_version_check_badge.vue'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; +import { __ } from '~/locale'; +import { STORAGE_KEY } from '~/whats_new/utils/notification'; + +export default { + components: { + GlBadge, + GlButton, + GlDisclosureDropdown, + GlDisclosureDropdownGroup, + GitlabVersionCheckBadge, + }, + i18n: { + help: __('Help'), + support: __('Support'), + docs: __('GitLab documentation'), + plans: __('Compare GitLab plans'), + forum: __('Community forum'), + contribute: __('Contribute to GitLab'), + feedback: __('Provide feedback'), + shortcuts: __('Keyboard shortcuts'), + version: __('Your GitLab version'), + whatsnew: __("What's new"), + }, + props: { + sidebarData: { + type: Object, + required: true, + }, + }, + data() { + return { + showWhatsNewNotification: this.shouldShowWhatsNewNotification(), + }; + }, + computed: { + itemGroups() { + return { + versionCheck: { + items: [ + { + text: this.$options.i18n.version, + href: helpPagePath('update/index'), + version: `${this.sidebarData.gitlab_version.major}.${this.sidebarData.gitlab_version.minor}`, + }, + ], + }, + helpLinks: { + items: [ + { text: this.$options.i18n.help, href: helpPagePath() }, + { text: this.$options.i18n.support, href: this.sidebarData.support_path }, + { text: this.$options.i18n.docs, href: 'https://docs.gitlab.com' }, + { text: this.$options.i18n.plans, href: `${PROMO_URL}/pricing` }, + { text: this.$options.i18n.forum, href: 'https://forum.gitlab.com/' }, + { + text: this.$options.i18n.contribute, + href: helpPagePath('', { anchor: 'contributing-to-gitlab' }), + }, + { text: this.$options.i18n.feedback, href: 'https://about.gitlab.com/submit-feedback' }, + ], + }, + helpActions: { + items: [ + { + text: this.$options.i18n.shortcuts, + action: this.showKeyboardShortcuts, + shortcut: '?', + }, + this.sidebarData.display_whats_new && { + text: this.$options.i18n.whatsnew, + action: this.showWhatsNew, + count: + this.showWhatsNewNotification && + this.sidebarData.whats_new_most_recent_release_items_count, + }, + ].filter(Boolean), + }, + }; + }, + updateSeverity() { + return this.sidebarData.gitlab_version_check?.severity; + }, + }, + methods: { + shouldShowWhatsNewNotification() { + if ( + !this.sidebarData.display_whats_new || + localStorage.getItem(STORAGE_KEY) === this.sidebarData.whats_new_version_digest + ) { + return false; + } + return true; + }, + + handleAction({ action }) { + if (action) { + action(); + } + }, + + showKeyboardShortcuts() { + this.$refs.dropdown.close(); + window?.toggleShortcutsHelp(); + }, + + async showWhatsNew() { + this.$refs.dropdown.close(); + this.showWhatsNewNotification = false; + + if (!this.toggleWhatsNewDrawer) { + const appEl = document.getElementById('whats-new-app'); + const { default: toggleWhatsNewDrawer } = await import( + /* webpackChunkName: 'whatsNewApp' */ '~/whats_new' + ); + this.toggleWhatsNewDrawer = toggleWhatsNewDrawer; + this.toggleWhatsNewDrawer(appEl); + } else { + this.toggleWhatsNewDrawer(); + } + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown ref="dropdown"> + <template #toggle> + <gl-button category="tertiary" icon="question-o" class="btn-with-notification"> + <span v-if="showWhatsNewNotification" class="notification"></span> + {{ $options.i18n.help }} + </gl-button> + </template> + + <gl-disclosure-dropdown-group + v-if="sidebarData.show_version_check" + :group="itemGroups.versionCheck" + > + <template #list-item="{ item }"> + <a + :href="item.href" + tabindex="-1" + class="gl-display-flex gl-flex-direction-column gl-line-height-24 gl-text-gray-900 gl-hover-text-gray-900 gl-hover-text-decoration-none" + > + <span class="gl-font-sm gl-font-weight-bold"> + {{ item.text }} + <gl-emoji data-name="rocket" /> + </span> + <span> + <span class="gl-mr-2">{{ item.version }}</span> + <gitlab-version-check-badge v-if="updateSeverity" :status="updateSeverity" size="sm" /> + </span> + </a> + </template> + </gl-disclosure-dropdown-group> + + <gl-disclosure-dropdown-group + :group="itemGroups.helpLinks" + :bordered="sidebarData.show_version_check" + /> + + <gl-disclosure-dropdown-group :group="itemGroups.helpActions" bordered @action="handleAction"> + <template #list-item="{ item }"> + <button + tabindex="-1" + class="gl-bg-transparent gl-w-full gl-border-none gl-display-flex gl-justify-content-space-between gl-p-0 gl-text-gray-900" + > + {{ item.text }} + <gl-badge v-if="item.count" pill size="sm" variant="info">{{ item.count }}</gl-badge> + <kbd v-else-if="item.shortcut" class="flat">?</kbd> + </button> + </template> + </gl-disclosure-dropdown-group> + </gl-disclosure-dropdown> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue new file mode 100644 index 00000000000..edc13e305cf --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue @@ -0,0 +1,40 @@ +<script> +import { GlBadge, GlDisclosureDropdown } from '@gitlab/ui'; + +export default { + components: { + GlBadge, + GlDisclosureDropdown, + }, + props: { + items: { + type: Array, + required: true, + }, + }, + methods: { + navigate() { + this.$refs.link.click(); + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown :items="items" placement="center" @action="navigate"> + <template #toggle> + <slot></slot> + </template> + <template #list-item="{ item }"> + <a + ref="link" + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-hover-text-gray-900 gl-hover-text-decoration-none gl-text-gray-900" + :href="item.href" + tabindex="-1" + > + {{ item.text }} + <gl-badge pill size="sm" variant="neutral">{{ item.count || 0 }}</gl-badge> + </a> + </template> + </gl-disclosure-dropdown> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue index e2eac64f5ad..c4b769dcf24 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -4,7 +4,7 @@ import { context } from '../mock_data'; import UserBar from './user_bar.vue'; import ContextSwitcherToggle from './context_switcher_toggle.vue'; import ContextSwitcher from './context_switcher.vue'; -import BottomBar from './bottom_bar.vue'; +import HelpCenter from './help_center.vue'; export default { context, @@ -13,7 +13,7 @@ export default { UserBar, ContextSwitcherToggle, ContextSwitcher, - BottomBar, + HelpCenter, }, props: { sidebarData: { @@ -31,7 +31,8 @@ export default { <template> <aside - class="super-sidebar gl-fixed gl-bottom-0 gl-left-0 gl-display-flex gl-flex-direction-column gl-bg-gray-10 gl-border-r gl-border-gray-a-08 gl-z-index-9999" + id="super-sidebar" + class="super-sidebar gl-fixed gl-bottom-0 gl-left-0 gl-display-flex gl-flex-direction-column gl-bg-gray-10 gl-border-r gl-border-gray-a-08" data-testid="super-sidebar" > <user-bar :sidebar-data="sidebarData" /> @@ -42,8 +43,8 @@ export default { <context-switcher /> </gl-collapse> </div> - <div class="gl-px-3"> - <bottom-bar /> + <div class="gl-p-3"> + <help-center :sidebar-data="sidebarData" /> </div> </div> </aside> diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index 7ee1776bf07..ee72e8eafb4 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -1,10 +1,12 @@ <script> -import { GlAvatar, GlDropdown, GlIcon } from '@gitlab/ui'; +import { GlAvatar, GlDropdown, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import logo from '../../../../views/shared/_logo.svg'; +import CreateMenu from './create_menu.vue'; import Counter from './counter.vue'; +import MergeRequestMenu from './merge_request_menu.vue'; export default { logo, @@ -12,15 +14,19 @@ export default { GlAvatar, GlDropdown, GlIcon, + CreateMenu, NewNavToggle, Counter, + MergeRequestMenu, }, i18n: { + createNew: __('Create new...'), issues: __('Issues'), mergeRequests: __('Merge requests'), todoList: __('To-Do list'), }, directives: { + GlTooltip: GlTooltipDirective, SafeHtml, }, inject: ['rootPath', 'toggleNewNavEndpoint'], @@ -39,11 +45,7 @@ export default { <div class="gl-flex-grow-1"> <a v-safe-html="$options.logo" :href="rootPath"></a> </div> - <gl-dropdown variant="link" no-caret> - <template #button-content> - <gl-icon name="plus" class="gl-vertical-align-middle gl-text-black-normal" /> - </template> - </gl-dropdown> + <create-menu :groups="sidebarData.create_new_menu_groups" /> <button class="gl-border-none"> <gl-icon name="search" class="gl-vertical-align-middle" /> </button> @@ -56,17 +58,29 @@ export default { </div> <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2"> <counter + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues" + class="gl-flex-basis-third" icon="issues" :count="sidebarData.assigned_open_issues_count" :href="sidebarData.issues_dashboard_path" :label="$options.i18n.issues" /> + <merge-request-menu + class="gl-flex-basis-third gl-display-block!" + :items="sidebarData.merge_request_menu" + > + <counter + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.mergeRequests" + class="gl-w-full" + tabindex="-1" + icon="merge-request-open" + :count="sidebarData.total_merge_requests_count" + :label="$options.i18n.mergeRequests" + /> + </merge-request-menu> <counter - icon="merge-request-open" - :count="sidebarData.assigned_open_merge_requests_count" - :label="$options.i18n.mergeRequests" - /> - <counter + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList" + class="gl-flex-basis-third" icon="todo-done" :count="sidebarData.todos_pending_count" href="/dashboard/todos" diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue index eecf32f83df..0ae97a47170 100644 --- a/app/assets/javascripts/terms/components/app.vue +++ b/app/assets/javascripts/terms/components/app.vue @@ -2,7 +2,6 @@ import { GlButton, GlIntersectionObserver } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import { FLASH_TYPES, FLASH_CLOSED_EVENT } from '~/flash'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import csrf from '~/lib/utils/csrf'; @@ -26,6 +25,9 @@ export default { data() { return { acceptDisabled: true, + observer: new MutationObserver(() => { + this.setScrollableViewportHeight(); + }), }; }, computed: { @@ -34,23 +36,10 @@ export default { mounted() { this.renderGFM(); this.setScrollableViewportHeight(); - - this.$options.flashElements = [ - ...document.querySelectorAll( - Object.values(FLASH_TYPES) - .map((flashType) => `.flash-${flashType}`) - .join(','), - ), - ]; - - this.$options.flashElements.forEach((flashElement) => { - flashElement.addEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose); - }); + this.observer.observe(document.body, { childList: true, subtree: true }); }, beforeDestroy() { - this.$options.flashElements.forEach((flashElement) => { - flashElement.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose); - }); + this.observer.disconnect(); }, methods: { renderGFM() { @@ -70,10 +59,6 @@ export default { scrollHeight - clientHeight }px)`; }, - handleFlashClose(event) { - this.setScrollableViewportHeight(); - event.target.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose); - }, trackTrialAcceptTerms, }, }; @@ -96,7 +81,7 @@ export default { </gl-intersection-observer> </div> </div> - <div v-if="isLoggedIn" class="gl-display-flex gl-justify-content-end"> + <div v-if="isLoggedIn" class="gl-display-flex gl-justify-content-end gl-p-5"> <form v-if="permissions.canDecline" method="post" :action="paths.decline"> <gl-button type="submit">{{ $options.i18n.decline }}</gl-button> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue new file mode 100644 index 00000000000..feaf9072ee2 --- /dev/null +++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue @@ -0,0 +1,258 @@ +<script> +import { + GlAlert, + GlButton, + GlCard, + GlFormInput, + GlLink, + GlLoadingIcon, + GlSprintf, + GlToggle, +} from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import inboundAddProjectCIJobTokenScopeMutation from '../graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql'; +import inboundRemoveProjectCIJobTokenScopeMutation from '../graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql'; +import inboundUpdateCIJobTokenScopeMutation from '../graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql'; +import inboundGetCIJobTokenScopeQuery from '../graphql/queries/inbound_get_ci_job_token_scope.query.graphql'; +import inboundGetProjectsWithCIJobTokenScopeQuery from '../graphql/queries/inbound_get_projects_with_ci_job_token_scope.query.graphql'; +import TokenProjectsTable from './token_projects_table.vue'; + +export default { + i18n: { + toggleLabelTitle: s__('CICD|Allow access to this project with a CI_JOB_TOKEN'), + toggleHelpText: s__( + `CICD|Manage which projects can use their CI_JOB_TOKEN to access this project. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more.%{linkEnd}`, + ), + cardHeaderTitle: s__( + 'CICD|Allow CI job tokens from the following projects to access this project', + ), + settingDisabledMessage: s__( + 'CICD|Enable feature to allow job token access by the following projects.', + ), + addProject: __('Add project'), + cancel: __('Cancel'), + addProjectPlaceholder: __('Paste project path (i.e. gitlab-org/gitlab)'), + projectsFetchError: __('There was a problem fetching the projects'), + scopeFetchError: __('There was a problem fetching the job token scope value'), + }, + fields: [ + { + key: 'project', + label: __('Project with access'), + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-40p', + }, + { + key: 'namespace', + label: __('Namespace'), + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-40p', + }, + { + key: 'actions', + label: '', + tdClass: 'gl-text-right', + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-10p', + }, + ], + components: { + GlAlert, + GlButton, + GlCard, + GlFormInput, + GlLink, + GlLoadingIcon, + GlSprintf, + GlToggle, + TokenProjectsTable, + }, + inject: { + fullPath: { + default: '', + }, + }, + apollo: { + inboundJobTokenScopeEnabled: { + query: inboundGetCIJobTokenScopeQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update({ project }) { + return project.ciCdSettings.inboundJobTokenScopeEnabled; + }, + error() { + createAlert({ message: this.$options.i18n.scopeFetchError }); + }, + }, + projects: { + query: inboundGetProjectsWithCIJobTokenScopeQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update({ project }) { + return project?.ciJobTokenScope?.inboundAllowlist?.nodes ?? []; + }, + error() { + createAlert({ message: this.$options.i18n.projectsFetchError }); + }, + }, + }, + data() { + return { + inboundJobTokenScopeEnabled: null, + targetProjectPath: '', + projects: [], + }; + }, + computed: { + isProjectPathEmpty() { + return this.targetProjectPath === ''; + }, + ciJobTokenHelpPage() { + return helpPagePath('ci/jobs/ci_job_token#allow-access-to-your-project-with-a-job-token'); + }, + }, + methods: { + async updateCIJobTokenScope() { + try { + const { + data: { + ciCdSettingsUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: inboundUpdateCIJobTokenScopeMutation, + variables: { + input: { + fullPath: this.fullPath, + inboundJobTokenScopeEnabled: this.inboundJobTokenScopeEnabled, + }, + }, + }); + + if (errors.length) { + throw new Error(errors[0]); + } + } catch (error) { + this.inboundJobTokenScopeEnabled = !this.inboundJobTokenScopeEnabled; + createAlert({ message: error.message }); + } + }, + async addProject() { + try { + const { + data: { + ciJobTokenScopeAddProject: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: inboundAddProjectCIJobTokenScopeMutation, + variables: { + projectPath: this.fullPath, + targetProjectPath: this.targetProjectPath, + }, + }); + + if (errors.length) { + throw new Error(errors[0]); + } + } catch (error) { + createAlert({ message: error.message }); + } finally { + this.clearTargetProjectPath(); + this.getProjects(); + } + }, + async removeProject(removeTargetPath) { + try { + const { + data: { + ciJobTokenScopeRemoveProject: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: inboundRemoveProjectCIJobTokenScopeMutation, + variables: { + projectPath: this.fullPath, + targetProjectPath: removeTargetPath, + }, + }); + + if (errors.length) { + throw new Error(errors[0]); + } + } catch (error) { + createAlert({ message: error.message }); + } finally { + this.getProjects(); + } + }, + clearTargetProjectPath() { + this.targetProjectPath = ''; + }, + getProjects() { + this.$apollo.queries.projects.refetch(); + }, + }, +}; +</script> +<template> + <div> + <gl-loading-icon v-if="$apollo.loading" size="lg" class="gl-mt-5" /> + <template v-else> + <gl-toggle + v-model="inboundJobTokenScopeEnabled" + :label="$options.i18n.toggleLabelTitle" + @change="updateCIJobTokenScope" + > + <template #help> + <gl-sprintf :message="$options.i18n.toggleHelpText"> + <template #link="{ content }"> + <gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </template> + </gl-toggle> + + <div> + <gl-card class="gl-mt-5 gl-mb-3"> + <template #header> + <h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5> + </template> + <template #default> + <gl-form-input + v-model="targetProjectPath" + :placeholder="$options.i18n.addProjectPlaceholder" + /> + </template> + <template #footer> + <gl-button variant="confirm" :disabled="isProjectPathEmpty" @click="addProject"> + {{ $options.i18n.addProject }} + </gl-button> + <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button> + </template> + </gl-card> + <gl-alert + v-if="!inboundJobTokenScopeEnabled" + class="gl-mb-3" + variant="warning" + :dismissible="false" + :show-icon="false" + > + {{ $options.i18n.settingDisabledMessage }} + </gl-alert> + <token-projects-table + :projects="projects" + :table-fields="$options.fields" + @removeProject="removeProject" + /> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/token_access/components/opt_in_jwt.vue b/app/assets/javascripts/token_access/components/opt_in_jwt.vue new file mode 100644 index 00000000000..c774f37b1e4 --- /dev/null +++ b/app/assets/javascripts/token_access/components/opt_in_jwt.vue @@ -0,0 +1,125 @@ +<script> +import { GlLink, GlLoadingIcon, GlSprintf, GlToggle } from '@gitlab/ui'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; +import { createAlert } from '~/flash'; +import { __, s__ } from '~/locale'; +import updateOptInJwtMutation from '../graphql/mutations/update_opt_in_jwt.mutation.graphql'; +import getOptInJwtSettingQuery from '../graphql/queries/get_opt_in_jwt_setting.query.graphql'; +import { LIMIT_JWT_ACCESS_SNIPPET, OPT_IN_JWT_HELP_LINK } from '../constants'; + +export default { + i18n: { + labelText: s__('CICD|Limit JSON Web Token (JWT) access'), + helpText: s__( + `CICD|The JWT must be manually declared in each job that needs it. When disabled, the token is always available in all jobs in the pipeline. %{linkStart}Learn more.%{linkEnd}`, + ), + expandedText: s__( + 'CICD|Use the %{codeStart}secrets%{codeEnd} keyword to configure a job with a JWT.', + ), + copyToClipboard: __('Copy to clipboard'), + fetchError: s__('CICD|There was a problem fetching the token access settings.'), + updateError: s__('CICD|An error occurred while update the setting. Please try again.'), + }, + components: { + CodeInstruction, + GlLink, + GlLoadingIcon, + GlSprintf, + GlToggle, + }, + inject: ['fullPath'], + apollo: { + optInJwt: { + query: getOptInJwtSettingQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update({ + project: { + ciCdSettings: { optInJwt }, + }, + }) { + return optInJwt; + }, + error() { + createAlert({ message: this.$options.i18n.fetchError }); + }, + }, + }, + data() { + return { + optInJwt: null, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.optInJwt.loading; + }, + }, + methods: { + async updateOptInJwt() { + try { + const { + data: { + ciCdSettingsUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updateOptInJwtMutation, + variables: { + input: { + fullPath: this.fullPath, + optInJwt: this.optInJwt, + }, + }, + }); + + if (errors.length) { + throw new Error(errors[0]); + } + } catch (error) { + createAlert({ message: this.$options.i18n.updateError }); + } + }, + }, + OPT_IN_JWT_HELP_LINK, + LIMIT_JWT_ACCESS_SNIPPET, +}; +</script> +<template> + <div> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-5" /> + <template v-else> + <gl-toggle + v-model="optInJwt" + class="gl-mt-5" + :label="$options.i18n.labelText" + @change="updateOptInJwt" + > + <template #help> + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="$options.OPT_IN_JWT_HELP_LINK" class="inline-link" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </template> + </gl-toggle> + <div v-if="optInJwt" class="gl-mt-5" data-testid="opt-in-jwt-expanded-section"> + <gl-sprintf :message="$options.i18n.expandedText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + <code-instruction + class="gl-mt-3" + :instruction="$options.LIMIT_JWT_ACCESS_SNIPPET" + :copy-text="$options.i18n.copyToClipboard" + multiline + /> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/outbound_token_access.vue index fe99f3e1fdd..0deae1a1d82 100644 --- a/app/assets/javascripts/token_access/components/token_access.vue +++ b/app/assets/javascripts/token_access/components/outbound_token_access.vue @@ -35,6 +35,27 @@ export default { projectsFetchError: __('There was a problem fetching the projects'), scopeFetchError: __('There was a problem fetching the job token scope value'), }, + fields: [ + { + key: 'project', + label: __('Project that can be accessed'), + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-40p', + }, + { + key: 'namespace', + label: __('Namespace'), + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-40p', + }, + { + key: 'actions', + label: '', + tdClass: 'gl-text-right', + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-10p', + }, + ], components: { GlAlert, GlButton, @@ -93,7 +114,7 @@ export default { return this.targetProjectPath === ''; }, ciJobTokenHelpPage() { - return helpPagePath('ci/jobs/ci_job_token'); + return helpPagePath('ci/jobs/ci_job_token#limit-your-projects-job-token-access'); }, }, methods: { @@ -228,7 +249,11 @@ export default { > {{ $options.i18n.settingDisabledMessage }} </gl-alert> - <token-projects-table :projects="projects" @removeProject="removeProject" /> + <token-projects-table + :projects="projects" + :table-fields="$options.fields" + @removeProject="removeProject" + /> </div> </template> </div> diff --git a/app/assets/javascripts/token_access/components/token_access_app.vue b/app/assets/javascripts/token_access/components/token_access_app.vue new file mode 100644 index 00000000000..59d59757735 --- /dev/null +++ b/app/assets/javascripts/token_access/components/token_access_app.vue @@ -0,0 +1,27 @@ +<script> +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import OutboundTokenAccess from './outbound_token_access.vue'; +import InboundTokenAccess from './inbound_token_access.vue'; +import OptInJwt from './opt_in_jwt.vue'; + +export default { + components: { + OutboundTokenAccess, + InboundTokenAccess, + OptInJwt, + }, + mixins: [glFeatureFlagMixin()], + computed: { + inboundTokenAccessEnabled() { + return this.glFeatures.ciInboundJobTokenScope; + }, + }, +}; +</script> +<template> + <div> + <inbound-token-access v-if="inboundTokenAccessEnabled" class="gl-pb-5" /> + <outbound-token-access class="gl-py-5" /> + <opt-in-jwt /> + </div> +</template> diff --git a/app/assets/javascripts/token_access/components/token_projects_table.vue b/app/assets/javascripts/token_access/components/token_projects_table.vue index ce33478cbee..c00dd882895 100644 --- a/app/assets/javascripts/token_access/components/token_projects_table.vue +++ b/app/assets/javascripts/token_access/components/token_projects_table.vue @@ -1,32 +1,11 @@ <script> import { GlButton, GlTable } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; export default { i18n: { emptyText: s__('CI/CD|No projects have been added to the scope'), }, - fields: [ - { - key: 'project', - label: __('Projects that can be accessed'), - thClass: 'gl-border-t-none!', - columnClass: 'gl-w-40p', - }, - { - key: 'namespace', - label: __('Namespace'), - thClass: 'gl-border-t-none!', - columnClass: 'gl-w-40p', - }, - { - key: 'actions', - label: '', - tdClass: 'gl-text-right', - thClass: 'gl-border-t-none!', - columnClass: 'gl-w-10p', - }, - ], components: { GlButton, GlTable, @@ -41,6 +20,10 @@ export default { type: Array, required: true, }, + tableFields: { + type: Array, + required: true, + }, }, methods: { removeProject(project) { @@ -52,7 +35,7 @@ export default { <template> <gl-table :items="projects" - :fields="$options.fields" + :fields="tableFields" :tbody-tr-attr="{ 'data-testid': 'projects-token-table-row' }" :empty-text="$options.i18n.emptyText" show-empty diff --git a/app/assets/javascripts/token_access/constants.js b/app/assets/javascripts/token_access/constants.js new file mode 100644 index 00000000000..fb2128462f0 --- /dev/null +++ b/app/assets/javascripts/token_access/constants.js @@ -0,0 +1,14 @@ +import { helpPagePath } from '~/helpers/help_page_helper'; + +export const LIMIT_JWT_ACCESS_SNIPPET = `job_name: + id_tokens: + ID_TOKEN_1: # or any other name + aud: "..." # sub-keyword to configure the token's audience + secrets: + TEST_SECRET: + vault: db/prod +`; + +export const OPT_IN_JWT_HELP_LINK = helpPagePath('ci/secrets/id_token_authentication', { + anchor: 'automatic-id-token-authentication-with-hashicorp-vault', +}); diff --git a/app/assets/javascripts/token_access/graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql new file mode 100644 index 00000000000..f030a892af2 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql @@ -0,0 +1,7 @@ +mutation inboundAddProjectCIJobTokenScope($projectPath: ID!, $targetProjectPath: ID!) { + ciJobTokenScopeAddProject( + input: { projectPath: $projectPath, targetProjectPath: $targetProjectPath, direction: INBOUND } + ) { + errors + } +} diff --git a/app/assets/javascripts/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql new file mode 100644 index 00000000000..cc6736bb80d --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql @@ -0,0 +1,7 @@ +mutation inboundRemoveProjectCIJobTokenScope($projectPath: ID!, $targetProjectPath: ID!) { + ciJobTokenScopeRemoveProject( + input: { projectPath: $projectPath, targetProjectPath: $targetProjectPath, direction: INBOUND } + ) { + errors + } +} diff --git a/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql new file mode 100644 index 00000000000..aac9feab237 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql @@ -0,0 +1,8 @@ +mutation inboundUpdateCIJobTokenScope($input: CiCdSettingsUpdateInput!) { + ciCdSettingsUpdate(input: $input) { + ciCdSettings { + inboundJobTokenScopeEnabled + } + errors + } +} diff --git a/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql new file mode 100644 index 00000000000..c12b5646423 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql @@ -0,0 +1,8 @@ +mutation updateOptInJwt($input: CiCdSettingsUpdateInput!) { + ciCdSettingsUpdate(input: $input) { + ciCdSettings { + optInJwt + } + errors + } +} diff --git a/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql new file mode 100644 index 00000000000..a1a216b7dc3 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql @@ -0,0 +1,8 @@ +query getOptInJwtSetting($fullPath: ID!) { + project(fullPath: $fullPath) { + id + ciCdSettings { + optInJwt + } + } +} diff --git a/app/assets/javascripts/token_access/graphql/queries/inbound_get_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/inbound_get_ci_job_token_scope.query.graphql new file mode 100644 index 00000000000..68d506a6c41 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/queries/inbound_get_ci_job_token_scope.query.graphql @@ -0,0 +1,8 @@ +query inboundGetCIJobTokenScope($fullPath: ID!) { + project(fullPath: $fullPath) { + id + ciCdSettings { + inboundJobTokenScopeEnabled + } + } +} diff --git a/app/assets/javascripts/token_access/graphql/queries/inbound_get_projects_with_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/inbound_get_projects_with_ci_job_token_scope.query.graphql new file mode 100644 index 00000000000..c51bdcbf7d2 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/queries/inbound_get_projects_with_ci_job_token_scope.query.graphql @@ -0,0 +1,18 @@ +query inboundGetProjectsWithCIJobTokenScope($fullPath: ID!) { + project(fullPath: $fullPath) { + id + ciJobTokenScope { + inboundAllowlist { + nodes { + id + name + namespace { + id + fullPath + } + fullPath + } + } + } + } +} diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js index 6a29883290a..0253abe393e 100644 --- a/app/assets/javascripts/token_access/index.js +++ b/app/assets/javascripts/token_access/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import TokenAccess from './components/token_access.vue'; +import TokenAccessApp from './components/token_access_app.vue'; Vue.use(VueApollo); @@ -25,7 +25,7 @@ export const initTokenAccess = (containerId = 'js-ci-token-access-app') => { fullPath, }, render(createElement) { - return createElement(TokenAccess); + return createElement(TokenAccessApp); }, }); }; diff --git a/app/assets/javascripts/tracking/get_standard_context.js b/app/assets/javascripts/tracking/get_standard_context.js index 6014f1ba3ee..df527e24d93 100644 --- a/app/assets/javascripts/tracking/get_standard_context.js +++ b/app/assets/javascripts/tracking/get_standard_context.js @@ -10,7 +10,7 @@ export default function getStandardContext({ extra = {} } = {}) { ...data, source: SNOWPLOW_JS_SOURCE, google_analytics_id: getCookie(GOOGLE_ANALYTICS_ID_COOKIE_NAME) ?? '', - extra: extra || data.extra, + extra: { ...data.extra, ...extra }, }, }; } diff --git a/app/assets/javascripts/usage_quotas/components/usage_quotas_app.vue b/app/assets/javascripts/usage_quotas/components/usage_quotas_app.vue new file mode 100644 index 00000000000..9322171cad8 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/components/usage_quotas_app.vue @@ -0,0 +1,35 @@ +<script> +import { GlSprintf, GlTab, GlTabs } from '@gitlab/ui'; +import { USAGE_QUOTAS_TITLE, USAGE_QUOTAS_SUBTITLE } from '../constants'; + +export default { + name: 'UsageQuotasApp', + components: { GlSprintf, GlTab, GlTabs }, + inject: ['namespaceName'], + computed: { + placeholder() { + return `storage_app_placeholder`; + }, + }, + USAGE_QUOTAS_TITLE, + USAGE_QUOTAS_SUBTITLE, +}; +</script> + +<template> + <section> + <h1>{{ $options.USAGE_QUOTAS_TITLE }}</h1> + <p data-testid="usage-quotas-page-subtitle"> + <gl-sprintf :message="$options.USAGE_QUOTAS_SUBTITLE"> + <template #namespaceName> + <strong> + {{ namespaceName }} + </strong> + </template> + </gl-sprintf> + </p> + <gl-tabs> + <gl-tab title="Storage"> {{ placeholder }} </gl-tab> + </gl-tabs> + </section> +</template> diff --git a/app/assets/javascripts/usage_quotas/constants.js b/app/assets/javascripts/usage_quotas/constants.js new file mode 100644 index 00000000000..f637d241778 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/constants.js @@ -0,0 +1,7 @@ +import { s__ } from '~/locale'; + +export const USAGE_QUOTAS_TITLE = s__('UsageQuota|Usage Quotas'); + +export const USAGE_QUOTAS_SUBTITLE = s__( + 'UsageQuota|Usage of group resources across the projects in the %{namespaceName} group', +); diff --git a/app/assets/javascripts/usage_quotas/index.js b/app/assets/javascripts/usage_quotas/index.js new file mode 100644 index 00000000000..e1032cd8d54 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import UsageQuotasApp from './components/usage_quotas_app.vue'; + +export default () => { + const el = document.getElementById('js-usage-quotas-view'); + + if (!el) { + return false; + } + + const { namespaceName } = el.dataset; + + return new Vue({ + el, + name: 'UsageQuotasView', + provide: { + namespaceName, + }, + render(createElement) { + return createElement(UsageQuotasApp); + }, + }); +}; diff --git a/app/assets/javascripts/users/profile/components/report_abuse_button.vue b/app/assets/javascripts/users/profile/components/report_abuse_button.vue index aabb7fde396..0e41a214888 100644 --- a/app/assets/javascripts/users/profile/components/report_abuse_button.vue +++ b/app/assets/javascripts/users/profile/components/report_abuse_button.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { s__ } from '~/locale'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; @@ -14,8 +14,9 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: ['reportedUserId', 'reportedFromUrl'], i18n: { - reportAbuse: __('Report abuse to administrator'), + reportAbuse: s__('ReportAbuse|Report abuse to administrator'), }, data() { return { @@ -28,11 +29,8 @@ export default { }, }, methods: { - openDrawer() { - this.open = true; - }, - closeDrawer() { - this.open = false; + toggleDrawer(open) { + this.open = open; }, hideTooltips() { this.$root.$emit(BV_HIDE_TOOLTIP); @@ -47,9 +45,14 @@ export default { category="primary" :aria-label="buttonTooltipText" icon="error" - @click="openDrawer" + @click="toggleDrawer(true)" @mouseout="hideTooltips" /> - <abuse-category-selector :show-drawer="open" @close-drawer="closeDrawer" /> + <abuse-category-selector + :reported-user-id="reportedUserId" + :reported-from-url="reportedFromUrl" + :show-drawer="open" + @close-drawer="toggleDrawer(false)" + /> </span> </template> diff --git a/app/assets/javascripts/users/profile/index.js b/app/assets/javascripts/users/profile/index.js index 37f8e3ac471..c6b85489785 100644 --- a/app/assets/javascripts/users/profile/index.js +++ b/app/assets/javascripts/users/profile/index.js @@ -10,7 +10,12 @@ export const initReportAbuse = () => { return new Vue({ el, - provide: { reportAbusePath, reportedUserId, reportedFromUrl }, + name: 'ReportAbuseButtonRoot', + provide: { + reportAbusePath, + reportedUserId: parseInt(reportedUserId, 10), + reportedFromUrl, + }, render(createElement) { return createElement(ReportAbuseButton); }, diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 7c1204c511c..1af47b020f7 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -447,7 +447,7 @@ function UsersSelect(currentUser, els, options = {}) { hidden() { if ($dropdown.hasClass('js-multiselect')) { if ($dropdown.hasClass(elsClassName)) { - if (window.gon?.features?.realtimeReviewers) { + if (!$dropdown.closest('.merge-request-form').length) { $dropdown.data('deprecatedJQueryDropdown').clearMenu(); $dropdown.closest('.selectbox').children('input[type="hidden"]').remove(); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue index 5339d7faf85..917ed259dd0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue @@ -35,6 +35,12 @@ export default { return sprintf(__('%{widget} options'), { widget: this.widget }); }, + hasOneOption() { + return this.tertiaryButtons.length === 1; + }, + hasMultipleOptions() { + return this.tertiaryButtons.length > 1; + }, }, methods: { onClickAction(action) { @@ -75,34 +81,59 @@ export default { <template> <div class="gl-display-flex gl-align-items-flex-start"> - <gl-dropdown - v-if="tertiaryButtons.length" - v-gl-tooltip - :title="__('Options')" - :text="dropdownLabel" - icon="ellipsis_v" - no-caret - category="tertiary" - right - lazy - text-sr-only - size="small" - toggle-class="gl-p-2!" - class="gl-display-block gl-md-display-none!" - > - <gl-dropdown-item + <template v-if="hasOneOption"> + <gl-button v-for="(btn, index) in tertiaryButtons" + :id="btn.id" :key="index" + v-gl-tooltip.hover + :title="setTooltip(btn)" :href="btn.href" :target="btn.target" + :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]" :data-clipboard-text="btn.dataClipboardText" + :data-qa-selector="actionButtonQaSelector(btn)" :data-method="btn.dataMethod" + :icon="btn.icon" + :data-testid="btn.testId || 'extension-actions-button'" + :variant="btn.variant || 'confirm'" + :loading="btn.loading" + :disabled="btn.loading" + category="tertiary" + size="small" + class="gl-md-display-block gl-float-left" @click="onClickAction(btn)" > {{ btn.text }} - </gl-dropdown-item> - </gl-dropdown> - <template v-if="tertiaryButtons.length"> + </gl-button> + </template> + <template v-if="hasMultipleOptions"> + <gl-dropdown + v-gl-tooltip + :title="__('Options')" + :text="dropdownLabel" + icon="ellipsis_v" + no-caret + category="tertiary" + right + lazy + text-sr-only + size="small" + toggle-class="gl-p-2!" + class="gl-display-block gl-md-display-none!" + > + <gl-dropdown-item + v-for="(btn, index) in tertiaryButtons" + :key="index" + :href="btn.href" + :target="btn.target" + :data-clipboard-text="btn.dataClipboardText" + :data-method="btn.dataMethod" + @click="onClickAction(btn)" + > + {{ btn.text }} + </gl-dropdown-item> + </gl-dropdown> <gl-button v-for="(btn, index) in tertiaryButtons" :id="btn.id" 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 eb93f42e2f3..4b65d6fd9ac 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,6 +2,7 @@ import { GlButton, GlSprintf, GlLink } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__, __ } from '~/locale'; import eventHub from '../../event_hub'; @@ -61,6 +62,7 @@ export default { fetchingApprovals: true, hasApprovalAuthError: false, isApproving: false, + updatedCount: 0, }; }, computed: { @@ -139,9 +141,11 @@ export default { this.fetchingApprovals = false; }) .catch(() => - createAlert({ - message: FETCH_ERROR, - }), + this.alerts.push( + createAlert({ + message: FETCH_ERROR, + }), + ), ); }, methods: { @@ -154,22 +158,26 @@ export default { this.updateApproval( () => this.service.approveMergeRequest(), () => - createAlert({ - message: APPROVE_ERROR, - }), + this.alerts.push( + createAlert({ + message: APPROVE_ERROR, + }), + ), ); }, approveWithAuth(data) { this.updateApproval( () => this.service.approveMergeRequestWithAuth(data), (error) => { - if (error && error.response && error.response.status === 401) { + if (error && error.response && error.response.status === HTTP_STATUS_UNAUTHORIZED) { this.hasApprovalAuthError = true; return; } - createAlert({ - message: APPROVE_ERROR, - }); + this.alerts.push( + createAlert({ + message: APPROVE_ERROR, + }), + ); }, ); }, @@ -177,9 +185,11 @@ export default { this.updateApproval( () => this.service.unapproveMergeRequest(), () => - createAlert({ - message: UNAPPROVE_ERROR, - }), + this.alerts.push( + createAlert({ + message: UNAPPROVE_ERROR, + }), + ), ); }, updateApproval(serviceFn, errFn) { @@ -188,6 +198,7 @@ export default { return serviceFn() .then((data) => { this.mr.setApprovals(data); + this.updatedCount += 1; if (!window.gon?.features?.realtimeMrStatusChange) { eventHub.$emit('MRWidgetUpdateRequested'); @@ -241,10 +252,10 @@ export default { /> <approvals-summary v-else - :approved="isApproved" - :approvals-left="approvals.approvals_left || 0" - :rules-left="approvals.approvalRuleNamesLeft" - :approvers="approvedBy" + :project-path="mr.targetProjectFullPath" + :iid="`${mr.iid}`" + :updated-count="updatedCount" + :multiple-approval-rules-available="mr.multipleApprovalRulesAvailable" /> </div> <div v-if="hasInvalidRules" class="gl-text-gray-400 gl-mt-2" data-testid="invalid-rules"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue index d7255eb6ad2..697d953874c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -1,4 +1,5 @@ <script> +import { GlSkeletonLoader } from '@gitlab/ui'; import { toNounSeriesText } from '~/lib/utils/grammar'; import { n__, sprintf } from '~/locale'; import { @@ -7,32 +8,68 @@ import { APPROVED_BY_OTHERS, } from '~/vue_merge_request_widget/components/approvals/messages'; import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getApprovalRuleNamesLeft } from 'ee_else_ce/vue_merge_request_widget/mappers'; +import approvedByQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql'; export default { + apollo: { + approvalState: { + query: approvedByQuery, + variables() { + return { + projectPath: this.projectPath, + iid: this.iid, + }; + }, + update: (data) => data.project.mergeRequest, + }, + }, components: { + GlSkeletonLoader, UserAvatarList, }, props: { - approved: { - type: Boolean, + projectPath: { + type: String, required: true, }, - approvalsLeft: { - type: Number, + iid: { + type: String, required: true, }, - rulesLeft: { - type: Array, + updatedCount: { + type: Number, required: false, - default: () => [], + default: 0, }, - approvers: { - type: Array, + multipleApprovalRulesAvailable: { + type: Boolean, required: false, - default: () => [], + default: false, }, }, + data() { + return { + approvalState: {}, + }; + }, computed: { + approvers() { + return this.approvalState.approvedBy?.nodes || []; + }, + approved() { + return this.approvalState.approved || this.approvalState.approvedBy?.nodes.length > 0; + }, + approvalsLeft() { + return this.approvalState.approvalsLeft || 0; + }, + rulesLeft() { + return getApprovalRuleNamesLeft( + this.multipleApprovalRulesAvailable, + (this.approvalState.approvalState?.rules || []).filter((r) => !r.approved), + ); + }, approvalLeftMessage() { if (this.rulesLeft.length) { return sprintf( @@ -81,32 +118,53 @@ export default { if (!this.currentUserId) { return false; } - return this.approvers.some((approver) => approver.id === this.currentUserId); + return this.approvers.some( + (approver) => getIdFromGraphQLId(approver.id) === this.currentUserId, + ); }, approvedByOthers() { if (!this.currentUserId) { return false; } - return this.approvers.some((approver) => approver.id !== this.currentUserId); + return this.approvers.some( + (approver) => getIdFromGraphQLId(approver.id) !== this.currentUserId, + ); }, currentUserId() { return gon.current_user_id; }, }, + watch: { + updatedCount() { + this.$apollo.queries.approvalState.refetch(); + }, + }, }; </script> <template> <div data-qa-selector="approvals_summary_content"> - <span class="gl-font-weight-bold">{{ approvalLeftMessage }}</span> - <template v-if="hasApprovers"> - <span v-if="approvalLeftMessage">{{ message }}</span> - <span v-else class="gl-font-weight-bold">{{ message }}</span> - <user-avatar-list - class="gl-display-inline-block gl-vertical-align-middle gl-pt-1" - :img-size="24" - :items="approvers" - /> + <div + v-if="$apollo.queries.approvalState.loading" + class="gl-display-inline-block gl-vertical-align-middle" + style="width: 132px; height: 24px" + > + <gl-skeleton-loader :width="132" :height="24"> + <rect width="100" height="24" x="0" y="0" rx="4" /> + <circle cx="120" cy="12" r="12" /> + </gl-skeleton-loader> + </div> + <template v-else> + <span class="gl-font-weight-bold">{{ approvalLeftMessage }}</span> + <template v-if="hasApprovers"> + <span v-if="approvalLeftMessage">{{ message }}</span> + <span v-else class="gl-font-weight-bold">{{ message }}</span> + <user-avatar-list + class="gl-display-inline-block gl-vertical-align-middle gl-pt-1" + :img-size="24" + :items="approvers" + /> + </template> </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql new file mode 100644 index 00000000000..c8cae6a8885 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql @@ -0,0 +1,16 @@ +query approvedBy($projectPath: ID!, $iid: String!) { + project(fullPath: $projectPath) { + id + mergeRequest(iid: $iid) { + id + approvedBy { + nodes { + id + name + avatarUrl + webUrl + } + } + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/bold_text.vue b/app/assets/javascripts/vue_merge_request_widget/components/bold_text.vue new file mode 100644 index 00000000000..bef1d79a655 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/bold_text.vue @@ -0,0 +1,26 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; + +export default { + name: 'BoldText', + components: { + GlSprintf, + }, + props: { + message: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <span> + <gl-sprintf :message="message"> + <template #bold="{ content }"> + <span class="gl-font-weight-bold" v-text="content"></span> + </template> + </gl-sprintf> + </span> +</template> 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 7cfc9431c2a..b78293a9815 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 @@ -293,7 +293,7 @@ export default { } }, onClickedAction(action) { - if (action.fullReport) { + if (action.trackFullReportClicked) { this.telemetry?.fullReportClicked(); } }, @@ -323,16 +323,25 @@ export default { data-testid="widget-extension-top-level" > <div - class="gl-flex-grow-1 gl-display-flex gl-align-items-center" + class="gl-flex-grow-1 gl-display-flex gl-align-items-center gl-flex-wrap" data-testid="widget-extension-top-level-summary" > - <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> - <template v-else-if="hasFetchError">{{ widgetErrorText }}</template> + <div v-if="isLoadingSummary" class="gl-w-full gl-line-height-normal"> + {{ widgetLoadingText }} + </div> + <div v-else-if="hasFetchError" class="gl-w-full gl-line-height-normal"> + {{ widgetErrorText }} + </div> <template v-else> - <span v-safe-html="hydratedSummary.subject"></span> + <div + v-safe-html="hydratedSummary.subject" + class="gl-w-full gl-line-height-normal" + ></div> <template v-if="hydratedSummary.meta"> - <br /> - <span v-safe-html="hydratedSummary.meta" class="gl-font-sm"></span> + <div + v-safe-html="hydratedSummary.meta" + class="gl-w-full gl-font-sm gl-line-height-normal" + ></div> </template> </template> </div> 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 d8a361066f4..2dec95c3fda 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 @@ -11,6 +11,7 @@ import { import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, n__ } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -86,6 +87,10 @@ export default { }, }, computed: { + downstreamPipelines() { + const downstream = this.pipeline.triggered; + return keepLatestDownstreamPipelines(downstream); + }, hasPipeline() { return this.pipeline && Object.keys(this.pipeline).length > 0; }, @@ -196,14 +201,13 @@ export default { <div class="ci-widget-content"> <div class="media-body"> <div - class="gl-font-weight-bold" data-testid="pipeline-info-container" data-qa-selector="merge_request_pipeline_info_content" > - {{ pipeline.details.event_type_name || pipeline.details.name }} + {{ pipeline.details.event_type_name }} <gl-link :href="pipeline.path" - class="pipeline-id gl-font-weight-normal pipeline-number" + class="pipeline-id" data-testid="pipeline-id" data-qa-selector="pipeline_link" >#{{ pipeline.id }}</gl-link @@ -275,7 +279,7 @@ export default { <span class="gl-align-items-center gl-display-inline-flex"> <pipeline-mini-graph v-if="pipeline.details.stages" - :downstream-pipelines="pipeline.triggered" + :downstream-pipelines="downstreamPipelines" :is-merge-train="isMergeTrain" :pipeline-path="pipeline.path" :stages="pipeline.details.stages" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue index ecf08f78f57..34a1d1facda 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue @@ -5,16 +5,32 @@ export default { hasChildren: false, }; }, - updated() { - this.hasChildren = this.checkSlots(); - }, mounted() { - this.hasChildren = this.checkSlots(); + const setHasChildren = () => { + this.hasChildren = Boolean(this.$el.innerText.trim()); + }; + + // Set initial. + setHasChildren(); + + if (!this.hasChildren) { + // Observe children changed. + this.observer = new MutationObserver(() => { + setHasChildren(); + + if (this.hasChildren) { + this.observer.disconnect(); + this.observer = undefined; + } + }); + + this.observer.observe(this.$el, { childList: true, subtree: true }); + } }, - methods: { - checkSlots() { - return this.$scopedSlots.default?.()?.some((c) => c.tag); - }, + beforeUnmount() { + if (this.observer) { + this.observer.disconnect(); + } }, }; </script> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue index e5688091cc7..6d7ec607557 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue @@ -1,17 +1,23 @@ <script> import { s__ } from '~/locale'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import StateContainer from '../state_container.vue'; import { DETAILED_MERGE_STATUS } from '../../constants'; export default { i18n: { - approvalNeeded: s__('mrWidget|Merge blocked: all required approvals must be given.'), + approvalNeeded: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} all required approvals must be given.', + ), blockingMergeRequests: s__( - 'mrWidget|Merge blocked: you can only merge after the above items are resolved.', + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} you can only merge after the above items are resolved.', + ), + externalStatusChecksFailed: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} all status checks must pass.', ), - externalStatusChecksFailed: s__('mrWidget|Merge blocked: all status checks must pass.'), }, components: { + BoldText, StateContainer, }, props: { @@ -38,10 +44,8 @@ export default { <template> <state-container :mr="mr" status="failed"> - <span - class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!" - > - {{ failedText }} + <span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"> + <bold-text :message="failedText" /> </span> </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue index 79e878431ed..837f8b32637 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue @@ -1,9 +1,17 @@ <script> +import { s__ } from '~/locale'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import StateContainer from '../state_container.vue'; +const message = s__( + 'mrWidget|%{boldStart}Merge unavailable:%{boldEnd} merge requests are read-only on archived projects.', +); + export default { name: 'MRWidgetArchived', + message, components: { + BoldText, StateContainer, }, props: { @@ -17,8 +25,6 @@ export default { <template> <state-container :mr="mr" status="failed"> - <span class="gl-font-weight-bold"> - {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }} - </span> + <bold-text :message="$options.message" /> </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index 922075516f3..670bd36d61e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -16,8 +16,6 @@ export default { </script> <template> <state-container :mr="mr" status="loading"> - <span class="gl-font-weight-bold"> - {{ s__('mrWidget|Checking if merge request can be merged…') }} - </span> + {{ s__('mrWidget|Checking if merge request can be merged…') }} </state-container> </template> 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 a5d982fe221..83d718f5a54 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,7 @@ <script> import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import userPermissionsQuery from '../../queries/permissions.query.graphql'; import conflictsStateQuery from '../../queries/states/conflicts.query.graphql'; @@ -8,6 +10,7 @@ import StateContainer from '../state_container.vue'; export default { name: 'MRWidgetConflicts', components: { + BoldText, GlSkeletonLoader, GlButton, StateContainer, @@ -55,6 +58,17 @@ export default { ); }, }, + i18n: { + shouldBeRebased: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} fast-forward merge is not possible. To merge this request, first rebase locally.', + ), + shouldBeResolved: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} merge conflicts must be resolved.', + ), + usersWriteBranches: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} Users who can write to the source or target branches can resolve the conflicts.', + ), + }, }; </script> <template> @@ -67,22 +81,13 @@ export default { </gl-skeleton-loader> </template> <template v-if="!isLoading"> - <span v-if="state.shouldBeRebased" class="bold gl-ml-0! gl-text-body!"> - {{ - s__(`mrWidget|Merge blocked: fast-forward merge is not possible. - To merge this request, first rebase locally.`) - }} + <span v-if="state.shouldBeRebased" class="gl-ml-0! gl-text-body!"> + <bold-text :message="$options.i18n.shouldBeRebased" /> </span> <template v-else> - <span class="bold gl-ml-0! gl-text-body! gl-flex-grow-1 gl-w-full gl-md-w-auto gl-mr-2"> - {{ s__('mrWidget|Merge blocked: merge conflicts must be resolved.') }} - <span v-if="!userPermissions.canMerge"> - {{ - s__( - `mrWidget|Users who can write to the source or target branches can resolve the conflicts.`, - ) - }} - </span> + <span class="gl-ml-0! gl-text-body! gl-flex-grow-1 gl-w-full gl-md-w-auto gl-mr-2"> + <bold-text v-if="userPermissions.canMerge" :message="$options.i18n.shouldBeResolved" /> + <bold-text v-else :message="$options.i18n.usersWriteBranches" /> </span> </template> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index 8a7f15d8d1a..bfc2c282f4c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -101,12 +101,14 @@ export default { </span> </state-container> <state-container v-else :mr="mr" status="failed" :actions="actions"> - <span class="gl-font-weight-bold"> - <span v-if="mr.mergeError" class="has-error-message" data-testid="merge-error"> - {{ mergeError }} - </span> - <span v-else> {{ s__('mrWidget|Merge failed.') }} </span> - <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span> + <span + v-if="mr.mergeError" + class="has-error-message gl-font-weight-bold" + data-testid="merge-error" + > + {{ mergeError }} </span> + <span v-else class="gl-font-weight-bold"> {{ s__('mrWidget|Merge failed.') }} </span> + <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span> </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue index 51ac2576f75..c94718ca756 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 @@ -2,6 +2,7 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import simplePoll from '~/lib/utils/simple_poll'; import MergeRequest from '~/merge_request'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import eventHub from '../../event_hub'; import { MERGE_ACTIVE_STATUS_PHRASES, STATE_MACHINE } from '../../constants'; import StatusIcon from '../mr_widget_status_icon.vue'; @@ -12,6 +13,7 @@ const { MERGE_FAILURE } = transitions; export default { name: 'MRWidgetMerging', components: { + BoldText, StatusIcon, }, props: { @@ -83,11 +85,9 @@ export default { <template> <div class="mr-widget-body mr-state-locked media"> <status-icon status="loading" /> - <div class="media-body"> - <h4> - {{ mergeStatus.message }} - <gl-emoji :data-name="mergeStatus.emoji" /> - </h4> + <div class="media-body" data-testid="merging-state"> + <bold-text :message="mergeStatus.message" /> + <gl-emoji :data-name="mergeStatus.emoji" /> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue index 5e073bf7c04..f1ddf94597b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue @@ -63,12 +63,14 @@ export default { <status-icon :show-disabled-button="true" status="failed" /> <div class="media-body space-children"> - <span class="gl-font-weight-bold js-branch-text" data-testid="widget-content"> - <gl-sprintf :message="warning"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> + <span class="js-branch-text" data-testid="widget-content"> + <span class="gl-font-weight-bold"> + <gl-sprintf :message="warning"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </span> {{ restore }} <gl-icon v-gl-tooltip diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue index d837551a813..536e61e57d3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue @@ -1,9 +1,17 @@ <script> +import { s__ } from '~/locale'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import StatusIcon from '../mr_widget_status_icon.vue'; +const message = s__( + 'mrWidget|%{boldStart}Ready to be merged automatically.%{boldEnd} Ask someone with write access to this repository to merge this request.', +); + export default { name: 'MRWidgetNotAllowed', + message, components: { + BoldText, StatusIcon, }, }; @@ -13,12 +21,7 @@ export default { <div class="mr-widget-body media"> <status-icon status="success" /> <div class="media-body space-children"> - <span class="gl-font-weight-bold"> - {{ - s__(`mrWidget|Ready to be merged automatically. -Ask someone with write access to this repository to merge this request`) - }} - </span> + <bold-text :message="$options.message" /> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue index 13920daca15..beb6310992f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue @@ -1,10 +1,18 @@ <script> +import { s__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import StatusIcon from '../mr_widget_status_icon.vue'; +const message = s__( + "mrWidget|%{boldStart}Merge blocked:%{boldEnd} pipeline must succeed. It's waiting for a manual action to continue.", +); + export default { name: 'MRWidgetPipelineBlocked', + message, components: { + BoldText, StatusIcon, }, mixins: [glFeatureFlagMixin()], @@ -14,13 +22,7 @@ export default { <div class="mr-widget-body media"> <status-icon status="failed" /> <div class="media-body space-children"> - <span class="gl-font-weight-bold"> - {{ - s__( - `mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`, - ) - }} - </span> + <bold-text :message="$options.message" /> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index d687f0346c7..ec6c2cf34c0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -1,16 +1,24 @@ <script> import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; import simplePoll from '~/lib/utils/simple_poll'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import rebaseQuery from '../../queries/states/rebase.query.graphql'; import StateContainer from '../state_container.vue'; +const i18n = { + rebaseError: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} the source branch must be rebased onto the target branch.', + ), +}; + export default { name: 'MRWidgetRebase', + i18n, apollo: { state: { query: rebaseQuery, @@ -21,6 +29,7 @@ export default { }, }, components: { + BoldText, GlSkeletonLoader, GlButton, StateContainer, @@ -69,9 +78,6 @@ export default { } return 'success'; }, - fastForwardMergeText() { - return __('Merge blocked: the source branch must be rebased onto the target branch.'); - }, showRebaseWithoutPipeline() { return ( !this.mr.onlyAllowMergeIfPipelineSucceeds || @@ -146,29 +152,29 @@ export default { <template v-if="!isLoading"> <span v-if="rebaseInProgress || isMakingRequest" - class="gl-ml-0! gl-text-body! gl-font-weight-bold" + class="gl-ml-0! gl-text-body!" data-testid="rebase-message" - >{{ __('Rebase in progress') }}</span + >{{ s__('mrWidget|Rebase in progress') }}</span > <span v-if="!rebaseInProgress && !canPushToSourceBranch" - class="gl-text-body! gl-font-weight-bold gl-ml-0!" + class="gl-text-body! gl-ml-0!" data-testid="rebase-message" - >{{ fastForwardMergeText }}</span > + <bold-text :message="$options.i18n.rebaseError" /> + </span> <div v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest" class="accept-merge-holder clearfix js-toggle-container media gl-md-display-flex gl-flex-wrap gl-flex-grow-1" > <span v-if="!rebasingError" - class="gl-font-weight-bold gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3" + class="gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3" data-testid="rebase-message" data-qa-selector="no_fast_forward_message_content" - >{{ - __('Merge blocked: the source branch must be rebased onto the target branch.') - }}</span > + <bold-text :message="$options.i18n.rebaseError" /> + </span> <span v-else class="gl-font-weight-bold danger gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-md-mr-3" @@ -187,7 +193,7 @@ export default { class="gl-align-self-start" @click="rebase" > - {{ __('Rebase') }} + {{ s__('mrWidget|Rebase') }} </gl-button> <gl-button v-if="showRebaseWithoutPipeline" @@ -199,7 +205,7 @@ export default { class="gl-align-self-start gl-mr-2" @click="rebaseWithoutCi" > - {{ __('Rebase without pipeline') }} + {{ s__('mrWidget|Rebase without pipeline') }} </gl-button> </template> </state-container> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue index 853895a4296..1896851952b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue @@ -2,11 +2,13 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import StatusIcon from '../mr_widget_status_icon.vue'; export default { name: 'PipelineFailed', components: { + BoldText, GlLink, GlSprintf, StatusIcon, @@ -24,7 +26,10 @@ export default { }, i18n: { failedMessage: s__( - `mrWidget|Merge blocked: pipeline must succeed. Push a commit that fixes the failure, or %{linkStart}learn about other solutions.%{linkEnd}`, + `mrWidget|%{boldStart}Merge blocked:%{boldEnd} pipeline must succeed. Push a commit that fixes the failure or %{linkStart}learn about other solutions.%{linkEnd}`, + ), + blockedMessage: s__( + "mrWidget|%{boldStart}Merge blocked:%{boldEnd} pipeline must succeed. It's waiting for a manual action to continue.", ), }, }; @@ -34,20 +39,17 @@ export default { <div class="mr-widget-body media"> <status-icon status="failed" /> <div class="media-body space-children"> - <span class="gl-font-weight-bold"> - <span v-if="mr.isPipelineBlocked"> - {{ - s__( - `mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`, - ) - }} - </span> + <span> + <bold-text v-if="mr.isPipelineBlocked" :message="$options.i18n.blockedMessage" /> <gl-sprintf v-else :message="$options.i18n.failedMessage"> <template #link="{ content }"> <gl-link :href="troubleshootingDocsPath" target="_blank"> {{ content }} </gl-link> </template> + <template #bold="{ content }"> + <span class="gl-font-weight-bold">{{ content }}</span> + </template> </gl-sprintf> </span> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 23b163e2c6a..bb8990a48b1 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 @@ -15,6 +15,7 @@ import { isEmpty } from 'lodash'; import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; import { createAlert } from '~/flash'; +import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import simplePoll from '~/lib/utils/simple_poll'; import { __, s__, n__ } from '~/locale'; @@ -98,7 +99,7 @@ export default { }, variables() { return { - issuableId: convertToGraphQLId('MergeRequest', this.mr?.id), + issuableId: convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.mr?.id), }; }, updateQuery( @@ -524,6 +525,7 @@ export default { v-model="removeSourceBranch" :disabled="isRemoveSourceBranchButtonDisabled" class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center gl-mr-5 gl-mb-3 gl-md-mb-0" + data-testid="delete-source-branch-checkbox" > {{ __('Delete source branch') }} </gl-form-checkbox> @@ -634,6 +636,7 @@ export default { v-gl-tooltip.hover.focus="__('Select merge moment')" :disabled="isMergeButtonDisabled" variant="confirm" + data-testid="merge-immediately-dropdown" data-qa-selector="merge_moment_dropdown" toggle-class="btn-icon js-merge-moment" > @@ -643,7 +646,8 @@ export default { </template> <gl-dropdown-item icon-name="warning" - button-class="accept-merge-request js-merge-immediately-button" + button-class="accept-merge-request" + data-testid="merge-immediately-button" data-qa-selector="merge_immediately_menu_item" @click="handleMergeImmediatelyButtonClick" > @@ -697,7 +701,11 @@ export default { :merge-commit-path="mr.mergeCommitPath" /> </li> - <li v-if="mr.state !== 'closed'" class="gl-line-height-normal"> + <li + v-if="mr.state !== 'closed'" + class="gl-line-height-normal" + data-testid="source-branch-deleted-text" + > {{ sourceBranchDeletedText }} </li> <li v-if="mr.relatedLinks" class="gl-line-height-normal"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue index 27919f90cc3..2aa345b420e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue @@ -1,11 +1,13 @@ <script> import { GlButton } from '@gitlab/ui'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import { I18N_SHA_MISMATCH } from '../../i18n'; import StateContainer from '../state_container.vue'; export default { name: 'ShaMismatch', components: { + BoldText, GlButton, StateContainer, }, @@ -24,10 +26,10 @@ export default { <template> <state-container :mr="mr" status="failed"> <span - class="gl-font-weight-bold gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!" + class="gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!" data-qa-selector="head_mismatch_content" > - {{ $options.i18n.I18N_SHA_MISMATCH.warningMessage }} + <bold-text :message="$options.i18n.I18N_SHA_MISMATCH.warningMessage" /> </span> <template #actions> <gl-button diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 9f3748599dc..0fd5551979d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -1,11 +1,17 @@ <script> import { GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; import notesEventHub from '~/notes/event_hub'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import StateContainer from '../state_container.vue'; +const message = s__('mrWidget|%{boldStart}Merge blocked:%{boldEnd} all threads must be resolved.'); + export default { name: 'UnresolvedDiscussions', + message, components: { + BoldText, GlButton, StateContainer, }, @@ -25,10 +31,8 @@ export default { <template> <state-container :mr="mr" status="failed"> - <span - class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!" - > - {{ s__('mrWidget|Merge blocked: all threads must be resolved.') }} + <span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"> + <bold-text :message="$options.message" /> </span> <template #actions> <gl-button diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index 211fbba305f..02d4f2499fe 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -2,18 +2,23 @@ import { GlButton } from '@gitlab/ui'; import { produce } from 'immer'; import { createAlert } from '~/flash'; -import toast from '~/vue_shared/plugins/global_toast'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import MergeRequest from '~/merge_request'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import getStateQuery from '../../queries/get_state.query.graphql'; import draftQuery from '../../queries/states/draft.query.graphql'; import removeDraftMutation from '../../queries/toggle_draft.mutation.graphql'; import StateContainer from '../state_container.vue'; -import eventHub from '../../event_hub'; + +// Export for testing +export const MSG_SOMETHING_WENT_WRONG = __('Something went wrong. Please try again.'); +export const MSG_MARK_READY = s__('mrWidget|Mark as ready'); export default { name: 'WorkInProgress', components: { + BoldText, GlButton, StateContainer, }, @@ -62,7 +67,7 @@ export default { ) { if (errors?.length) { createAlert({ - message: __('Something went wrong. Please try again.'), + message: MSG_SOMETHING_WENT_WRONG, }); return; @@ -109,19 +114,12 @@ export default { }, }, }) => { - toast(__('Marked as ready. Merging is now allowed.')); - document.querySelector( - '.merge-request .detail-page-description .title', - ).textContent = title; - - if (!window.gon?.features?.realtimeMrStatusChange) { - eventHub.$emit('MRWidgetUpdateRequested'); - } + MergeRequest.toggleDraftStatus(title, true); }, ) .catch(() => createAlert({ - message: __('Something went wrong. Please try again.'), + message: MSG_SOMETHING_WENT_WRONG, }), ) .finally(() => { @@ -129,13 +127,19 @@ export default { }); }, }, + i18n: { + removeDraftStatus: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} Select %{boldStart}Mark as ready%{boldEnd} to remove it from Draft status.', + ), + }, + MSG_MARK_READY, }; </script> <template> <state-container :mr="mr" status="failed"> - <span class="gl-font-weight-bold gl-ml-0! gl-text-body! gl-flex-grow-1"> - {{ __("Merge blocked: merge request must be marked as ready. It's still marked as draft.") }} + <span class="gl-ml-0! gl-text-body! gl-flex-grow-1"> + <bold-text :message="$options.i18n.removeDraftStatus" /> </span> <template #actions> <gl-button @@ -148,7 +152,7 @@ export default { data-testid="removeWipButton" @click="handleRemoveDraft" > - {{ s__('mrWidget|Mark as ready') }} + {{ $options.MSG_MARK_READY }} </gl-button> </template> </state-container> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue index 7343c98938c..73129a86877 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue @@ -2,6 +2,7 @@ import { GlButton, GlLink, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { normalizeHeaders } from '~/lib/utils/common_utils'; +import { logError } from '~/lib/logger'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { sprintf, __ } from '~/locale'; import Poll from '~/lib/utils/poll'; @@ -17,8 +18,12 @@ import ActionButtons from './action_buttons.vue'; const FETCH_TYPE_COLLAPSED = 'collapsed'; const FETCH_TYPE_EXPANDED = 'expanded'; const WIDGET_PREFIX = 'Widget'; +const MISSING_RESPONSE_HEADERS = + 'MR Widget: raesponse object should contain status and headers object. Make sure to include that in your `fetchCollapsedData` and `fetchExpandedData` functions.'; export default { + MISSING_RESPONSE_HEADERS, + components: { ActionButtons, StatusIcon, @@ -92,6 +97,23 @@ export default { type: Boolean, required: true, }, + /** + * A button is composed of the following properties: + * + * { + * "id": string, + * "href": string, + * "dataMethod": string, + * "dataClipboardText": string, + * "icon": string, + * "variant": string, + * "loading": boolean, + * "testId":string, + * "text": string, + * "class": string | Object, + * "trackFullReportClicked": boolean, + * } + */ actionButtons: { type: Array, required: false, @@ -182,7 +204,7 @@ export default { }, methods: { onActionClick(action) { - if (action.fullReport) { + if (action.trackFullReportClicked) { this.telemetryHub?.fullReportClicked(); } }, @@ -225,6 +247,14 @@ export default { }, method: 'fetchData', successCallback: (response) => { + if ( + typeof response.status === 'undefined' || + typeof response.headers === 'undefined' + ) { + logError(MISSING_RESPONSE_HEADERS); + throw new Error(MISSING_RESPONSE_HEADERS); + } + const headers = normalizeHeaders(response.headers); if (headers['POLL-INTERVAL']) { diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 7109bed7743..85ae298fcea 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -4,9 +4,7 @@ import { stateToComponentMap as classStateMap, stateKey } from './stores/state_m export const SUCCESS = 'success'; export const WARNING = 'warning'; -export const DANGER = 'danger'; export const INFO = 'info'; -export const CONFIRM = 'confirm'; export const MWPS_MERGE_STRATEGY = 'merge_when_pipeline_succeeds'; export const MTWPS_MERGE_STRATEGY = 'add_to_merge_train_when_pipeline_succeeds'; @@ -28,39 +26,39 @@ export const SP_ICON_NAME = 'status_notfound'; export const MERGE_ACTIVE_STATUS_PHRASES = [ { - message: s__('mrWidget|Merging! Drum roll, please…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Drum roll, please…'), emoji: 'drum', }, { - message: s__("mrWidget|Merging! We're almost there…"), + message: s__("mrWidget|%{boldStart}Merging!%{boldEnd} We're almost there…"), emoji: 'sparkles', }, { - message: s__('mrWidget|Merging! Changes will land soon…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Changes will land soon…'), emoji: 'airplane_arriving', }, { - message: s__('mrWidget|Merging! Changes are being shipped…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Changes are being shipped…'), emoji: 'ship', }, { - message: s__("mrWidget|Merging! Everything's good…"), + message: s__("mrWidget|%{boldStart}Merging!%{boldEnd} Everything's good…"), emoji: 'relieved', }, { - message: s__('mrWidget|Merging! This is going to be great…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} This is going to be great…'), emoji: 'heart_eyes', }, { - message: s__('mrWidget|Merging! Lift-off in 5… 4… 3…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Lift-off in 5… 4… 3…'), emoji: 'rocket', }, { - message: s__('mrWidget|Merging! The changes are leaving the station…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} The changes are leaving the station…'), emoji: 'bullettrain_front', }, { - message: s__('mrWidget|Merging! Take a deep breath and relax…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Take a deep breath and relax…'), emoji: 'sunglasses', }, ]; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index ca95e1b5de8..ff225afbc7b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -44,7 +44,12 @@ export default { console.log('Hello world'); }, }, - { text: 'Full report', href: this.conflictsDocsPath, target: '_blank', fullReport: true }, + { + text: 'Full report', + href: this.conflictsDocsPath, + target: '_blank', + trackFullReportClicked: true, + }, ]; }, shouldCollapse() { diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue index f0b20adc5cf..6155a912683 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue @@ -70,6 +70,9 @@ export default { artifacts() { return this.reportArtifacts || []; }, + hasSecurityReports() { + return this.artifacts.length > 0; + }, }, methods: { handleIsLoading(value) { @@ -99,6 +102,7 @@ export default { <template> <mr-widget + v-if="hasSecurityReports" :has-error="hasError" :error-text="$options.i18n.apiError" :status-icon-name="$options.icons.warning" @@ -108,7 +112,7 @@ export default { :summary="$options.i18n.scansHaveRun" @is-loading="handleIsLoading" > - <template v-if="artifacts.length > 0" #action-buttons> + <template #action-buttons> <div class="gl-ml-3"> <gl-dropdown v-gl-tooltip diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js index 626a99f7d64..c5cbed4a280 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js @@ -115,7 +115,7 @@ export default { href: report.job_path, text: this.$options.i18n.fullLog, target: '_blank', - fullReport: true, + trackFullReportClicked: true, }; actions.push(action); } 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 97b9b59e2c3..6ac462d4ad5 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,6 +1,7 @@ import { uniqueId } from 'lodash'; import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue'; import { EXTENSION_ICONS } from '../../constants'; import { @@ -74,7 +75,7 @@ export default { text: this.$options.i18n.fullReport, href: `${this.pipeline.path}/test_report`, target: '_blank', - fullReport: true, + trackFullReportClicked: true, testId: 'full-report-link', }); @@ -91,7 +92,7 @@ export default { ...response, data: { hasSuiteError: suites.some((suite) => suite.status === ERROR_STATUS), - parsingInProgress: status === 204, + parsingInProgress: status === HTTP_STATUS_NO_CONTENT, ...data, summary: { recentlyFailed: countRecentlyFailedTests(suites), diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js index 5380bcae003..5ca56074031 100644 --- a/app/assets/javascripts/vue_merge_request_widget/i18n.js +++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js @@ -17,7 +17,7 @@ export const SQUASH_BEFORE_MERGE = { }; export const I18N_SHA_MISMATCH = { - warningMessage: __('Merge blocked: new changes were just added.'), + warningMessage: s__('mrWidget|%{boldStart}Merge blocked:%{boldEnd} new changes were just added.'), actionButtonLabel: __('Review changes'), }; diff --git a/app/assets/javascripts/vue_merge_request_widget/mappers.js b/app/assets/javascripts/vue_merge_request_widget/mappers.js new file mode 100644 index 00000000000..63c4c3dc871 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mappers.js @@ -0,0 +1,3 @@ +export function getApprovalRuleNamesLeft(_, rules) { + return rules; +} diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js index 943011949fd..7d0871f696b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js @@ -1,14 +1,15 @@ -import { hideFlash } from '~/flash'; - export default { + data() { + return { + alerts: [], + }; + }, methods: { clearError() { this.$emit('clearError'); this.hasApprovalAuthError = false; - const flashEl = document.querySelector('.flash-alert'); - if (flashEl) { - hideFlash(flashEl); - } + this.alerts.forEach((alert) => alert.dismiss()); + this.alerts = []; }, refreshApprovals() { return this.service.fetchApprovals().then((data) => { 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 00024a594dc..ecbee6544ab 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 @@ -15,8 +15,9 @@ import notify from '~/lib/utils/notify'; import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; +import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { setFaviconOverlay } from '../lib/utils/favicon'; +import { setFaviconOverlay } from '~/lib/utils/favicon'; import Loading from './components/loading.vue'; import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue'; import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue'; @@ -120,7 +121,7 @@ export default { }, variables() { return { - issuableId: convertToGraphQLId('MergeRequest', this.mr?.id), + issuableId: convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.mr?.id), }; }, updateQuery( 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 85df2ea63c8..f6a7ef58c10 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 @@ -30,6 +30,7 @@ export default class MergeRequestStore { this.machineValue = this.stateMachine.value; this.mergeDetailsCollapsed = window.innerWidth < 768; this.mergeError = data.mergeError; + this.multipleApprovalRulesAvailable = data.multiple_approval_rules_available || false; this.id = data.id; this.setPaths(data); diff --git a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue index 3c73f42b6b1..634b7da3def 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue @@ -34,7 +34,7 @@ export default { <template> <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-p-0!"> - <div class="gl-display-inline-flex gl-align-items-center"> + <div class="gl-display-inline-flex gl-align-items-center gl-relative"> <div class="gl-display-inline gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-box-sizing-content-box gl-p-3 gl-mt-n2 gl-mr-6" > diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue index 49181bb847d..3a3929fba9b 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue @@ -16,7 +16,7 @@ export default { handleBlobRichViewer(this.$refs.content, this.type); }, safeHtmlConfig: { - ADD_TAGS: ['copy-code'], + ADD_TAGS: ['gl-emoji', 'copy-code'], }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index 271cfd210a6..52a5d6e1b86 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -42,11 +42,6 @@ export default { required: false, default: true, }, - iconClasses: { - type: String, - required: false, - default: '', - }, }, computed: { title() { @@ -73,7 +68,7 @@ export default { :href="detailsPath" @click="$emit('ciStatusBadgeClick')" > - <ci-icon :status="status" :css-classes="iconClasses" /> + <ci-icon :status="status" /> <template v-if="showText"> <span class="gl-ml-2">{{ status.text }}</span> diff --git a/app/assets/javascripts/vue_shared/components/entity_select/constants.js b/app/assets/javascripts/vue_shared/components/entity_select/constants.js new file mode 100644 index 00000000000..0fb5a2d5534 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/constants.js @@ -0,0 +1,16 @@ +import { __, s__ } from '~/locale'; + +export const RESET_LABEL = __('Reset'); +export const QUERY_TOO_SHORT_MESSAGE = __('Enter at least three characters to search.'); + +// Groups +export const GROUP_TOGGLE_TEXT = __('Search for a group'); +export const GROUP_HEADER_TEXT = __('Select a group'); +export const FETCH_GROUPS_ERROR = __('Unable to fetch groups. Reload the page to try again.'); +export const FETCH_GROUP_ERROR = __('Unable to fetch group. Reload the page to try again.'); + +// Projects +export const PROJECT_TOGGLE_TEXT = s__('ProjectSelect|Search for project'); +export const PROJECT_HEADER_TEXT = s__('ProjectSelect|Select a project'); +export const FETCH_PROJECTS_ERROR = __('Unable to fetch projects. Reload the page to try again.'); +export const FETCH_PROJECT_ERROR = __('Unable to fetch project. Reload the page to try again.'); diff --git a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue index d295052e2ce..45c50dce8ce 100644 --- a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue @@ -1,28 +1,15 @@ <script> import { debounce } from 'lodash'; -import { GlFormGroup, GlAlert, GlCollapsibleListbox } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; -import axios from '~/lib/utils/axios_utils'; -import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; -import Api from '~/api'; +import { GlFormGroup, GlCollapsibleListbox } from '@gitlab/ui'; import { __ } from '~/locale'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { groupsPath } from './utils'; -import { - TOGGLE_TEXT, - RESET_LABEL, - FETCH_GROUPS_ERROR, - FETCH_GROUP_ERROR, - QUERY_TOO_SHORT_MESSAGE, -} from './constants'; +import { RESET_LABEL, QUERY_TOO_SHORT_MESSAGE } from './constants'; const MINIMUM_QUERY_LENGTH = 3; -const GROUPS_PER_PAGE = 20; export default { components: { GlFormGroup, - GlAlert, GlCollapsibleListbox, }, props: { @@ -48,13 +35,20 @@ export default { required: false, default: false, }, - parentGroupID: { + headerText: { type: String, - required: false, - default: null, + required: true, }, - groupsFilter: { + defaultToggleText: { type: String, + required: true, + }, + fetchItems: { + type: Function, + required: true, + }, + fetchInitialSelectionText: { + type: Function, required: false, default: null, }, @@ -63,10 +57,10 @@ export default { return { pristine: true, searching: false, - hasMoreGroups: true, + hasMoreItems: true, infiniteScrollLoading: false, searchString: '', - groups: [], + items: [], page: 1, selectedValue: null, selectedText: null, @@ -78,14 +72,14 @@ export default { set(value) { this.selectedValue = value; this.selectedText = - value === null ? null : this.groups.find((group) => group.value === value).full_name; + value === null ? null : this.items.find((item) => item.value === value).text; }, get() { return this.selectedValue; }, }, toggleText() { - return this.selectedText ?? this.$options.i18n.toggleText; + return this.selectedText ?? this.defaultToggleText; }, resetButtonLabel() { return this.clearable ? RESET_LABEL : ''; @@ -109,90 +103,64 @@ export default { search: debounce(function debouncedSearch(searchString) { this.searchString = searchString; if (this.isSearchQueryTooShort) { - this.groups = []; + this.items = []; } else { - this.fetchGroups(); + this.fetchEntities(); } }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - async fetchGroups(page = 1) { + async fetchEntities(page = 1) { if (page === 1) { this.searching = true; - this.groups = []; - this.hasMoreGroups = true; + this.items = []; + this.hasMoreItems = true; } else { this.infiniteScrollLoading = true; } - try { - const { data, headers } = await axios.get( - Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)), - { - params: { - search: this.searchString, - per_page: GROUPS_PER_PAGE, - page, - }, - }, - ); - const groups = data.length ? data : data.results || []; - - this.groups.push( - ...groups.map((group) => ({ - ...group, - value: String(group.id), - })), - ); + const { items, totalPages } = await this.fetchItems(this.searchString, page); - const { totalPages } = parseIntPagination(normalizeHeaders(headers)); - if (page === totalPages) { - this.hasMoreGroups = false; - } + this.items.push(...items); - this.page = page; - this.searching = false; - this.infiniteScrollLoading = false; - } catch (error) { - this.handleError({ message: FETCH_GROUPS_ERROR, error }); + if (page === totalPages) { + this.hasMoreItems = false; } + + this.page = page; + this.searching = false; + this.infiniteScrollLoading = false; }, async fetchInitialSelection() { if (!this.initialSelection) { this.pristine = false; return; } - this.searching = true; - try { - const group = await Api.group(this.initialSelection); - this.selectedValue = this.initialSelection; - this.selectedText = group.full_name; - this.pristine = false; - this.searching = false; - } catch (error) { - this.handleError({ message: FETCH_GROUP_ERROR, error }); + + if (!this.fetchInitialSelectionText) { + throw new Error( + '`initialSelection` is provided but lacks `fetchInitialSelectionText` to retrieve the corresponding text', + ); } + + this.searching = true; + const name = await this.fetchInitialSelectionText(this.initialSelection); + this.selectedValue = this.initialSelection; + this.selectedText = name; + this.pristine = false; + this.searching = false; }, onShown() { - if (!this.searchString && !this.groups.length) { - this.fetchGroups(); + if (!this.searchString && !this.items.length) { + this.fetchEntities(); } }, onReset() { this.selected = null; }, onBottomReached() { - this.fetchGroups(this.page + 1); - }, - handleError({ message, error }) { - Sentry.captureException(error); - this.errorMessage = message; - }, - dismissError() { - this.errorMessage = ''; + this.fetchEntities(this.page + 1); }, }, i18n: { - toggleText: TOGGLE_TEXT, - selectGroup: __('Select a group'), noResultsText: __('No results found.'), searchQueryTooShort: QUERY_TOO_SHORT_MESSAGE, }, @@ -201,20 +169,21 @@ export default { <template> <gl-form-group :label="label"> - <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{ - errorMessage - }}</gl-alert> + <slot name="error"></slot> + <template v-if="Boolean($scopedSlots.label)" #label> + <slot name="label"></slot> + </template> <gl-collapsible-listbox ref="listbox" v-model="selected" - :header-text="$options.i18n.selectGroup" + :header-text="headerText" :reset-button-label="resetButtonLabel" :toggle-text="toggleText" :loading="searching && pristine" :searching="searching" - :items="groups" + :items="items" :no-results-text="noResultsText" - :infinite-scroll="hasMoreGroups" + :infinite-scroll="hasMoreItems" :infinite-scroll-loading="infiniteScrollLoading" searchable @shown="onShown" @@ -223,10 +192,7 @@ export default { @bottom-reached="onBottomReached" > <template #list-item="{ item }"> - <div class="gl-font-weight-bold"> - {{ item.full_name }} - </div> - <div class="gl-text-gray-300">{{ item.full_path }}</div> + <slot name="list-item" :item="item"></slot> </template> </gl-collapsible-listbox> <input :id="inputId" data-testid="input" type="hidden" :name="inputName" :value="inputValue" /> diff --git a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue new file mode 100644 index 00000000000..ff137d764ee --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue @@ -0,0 +1,137 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import axios from '~/lib/utils/axios_utils'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; +import Api, { DEFAULT_PER_PAGE } from '~/api'; +import { groupsPath } from './utils'; +import { + GROUP_TOGGLE_TEXT, + GROUP_HEADER_TEXT, + FETCH_GROUPS_ERROR, + FETCH_GROUP_ERROR, +} from './constants'; +import EntitySelect from './entity_select.vue'; + +export default { + components: { + GlAlert, + EntitySelect, + }, + props: { + label: { + type: String, + required: false, + default: '', + }, + inputName: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + initialSelection: { + type: String, + required: false, + default: null, + }, + clearable: { + type: Boolean, + required: false, + default: false, + }, + parentGroupID: { + type: String, + required: false, + default: null, + }, + groupsFilter: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + errorMessage: '', + }; + }, + methods: { + async fetchGroups(searchString = '', page = 1) { + let groups = []; + let totalPages = 0; + try { + const { data = [], headers } = await axios.get( + Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)), + { + params: { + search: searchString, + per_page: DEFAULT_PER_PAGE, + page, + }, + }, + ); + groups = data.map((group) => ({ + ...group, + text: group.full_name, + value: String(group.id), + })); + + totalPages = parseIntPagination(normalizeHeaders(headers)).totalPages; + } catch (error) { + this.handleError({ message: FETCH_GROUPS_ERROR, error }); + } + return { items: groups, totalPages }; + }, + async fetchGroupName(groupId) { + let groupName = ''; + try { + const group = await Api.group(groupId); + groupName = group.full_name; + } catch (error) { + this.handleError({ message: FETCH_GROUP_ERROR, error }); + } + return groupName; + }, + handleError({ message, error }) { + Sentry.captureException(error); + this.errorMessage = message; + }, + dismissError() { + this.errorMessage = ''; + }, + }, + i18n: { + toggleText: GROUP_TOGGLE_TEXT, + selectGroup: GROUP_HEADER_TEXT, + }, +}; +</script> + +<template> + <entity-select + :label="label" + :input-name="inputName" + :input-id="inputId" + :initial-selection="initialSelection" + :clearable="clearable" + :header-text="$options.i18n.selectGroup" + :default-toggle-text="$options.i18n.toggleText" + :fetch-items="fetchGroups" + :fetch-initial-selection-text="fetchGroupName" + > + <template #error> + <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{ + errorMessage + }}</gl-alert> + </template> + <template #list-item="{ item }"> + <div class="gl-font-weight-bold"> + {{ item.full_name }} + </div> + <div class="gl-text-gray-300">{{ item.full_path }}</div> + </template> + </entity-select> +</template> diff --git a/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js b/app/assets/javascripts/vue_shared/components/entity_select/init_group_selects.js index dbfac8a0339..dbfac8a0339 100644 --- a/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js +++ b/app/assets/javascripts/vue_shared/components/entity_select/init_group_selects.js diff --git a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js new file mode 100644 index 00000000000..1afbeda74c4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js @@ -0,0 +1,48 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import ProjectSelect from './project_select.vue'; + +const SELECTOR = '.js-vue-project-select'; + +export const initProjectSelects = () => { + if (process.env.NODE_ENV !== 'production' && document.querySelector(SELECTOR) === null) { + // eslint-disable-next-line no-console + console.warn(`Attempted to initialize ProjectSelect but '${SELECTOR}' not found in the page`); + } + + document.querySelectorAll(SELECTOR).forEach((el) => { + const { + label, + inputName, + inputId, + groupId, + userId, + orderBy, + selected: initialSelection, + } = el.dataset; + const includeSubgroups = parseBoolean(el.dataset.includeSubgroups); + const membership = parseBoolean(el.dataset.membership); + const hasHtmlLabel = parseBoolean(el.dataset.hasHtmlLabel); + + return new Vue({ + el, + name: 'ProjectSelectRoot', + render(createElement) { + return createElement(ProjectSelect, { + props: { + label, + hasHtmlLabel, + inputName, + inputId, + groupId, + userId, + orderBy, + includeSubgroups, + membership, + initialSelection, + }, + }); + }, + }); + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue new file mode 100644 index 00000000000..393991d746e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue @@ -0,0 +1,168 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import Api from '~/api'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { + PROJECT_TOGGLE_TEXT, + PROJECT_HEADER_TEXT, + FETCH_PROJECTS_ERROR, + FETCH_PROJECT_ERROR, +} from './constants'; +import EntitySelector from './entity_select.vue'; + +export default { + components: { + GlAlert, + EntitySelector, + }, + directives: { + SafeHtml, + }, + props: { + label: { + type: String, + required: true, + }, + hasHtmlLabel: { + type: Boolean, + required: false, + default: false, + }, + inputName: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + groupId: { + type: String, + required: false, + default: null, + }, + userId: { + type: String, + required: false, + default: null, + }, + includeSubgroups: { + type: Boolean, + required: false, + default: false, + }, + membership: { + type: Boolean, + required: false, + default: false, + }, + orderBy: { + type: String, + required: false, + default: 'similarity', + }, + initialSelection: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + errorMessage: '', + }; + }, + methods: { + async fetchProjects(searchString = '') { + let projects = []; + try { + const { data = [] } = await (() => { + const commonParams = { + order_by: this.orderBy, + simple: true, + }; + + if (this.groupId) { + return Api.groupProjects(this.groupId, searchString, { + ...commonParams, + with_shared: true, + include_subgroups: this.includeSubgroups, + simple: true, + }); + } + // Note: the whole userId handling supports a single project selector that is slated for + // removal. Once we have deleted app/views/clusters/clusters/_advanced_settings.html.haml, + // we should be able to clean this up. + if (this.userId) { + return Api.userProjects( + this.userId, + searchString, + { + with_shared: true, + include_subgroups: this.includeSubgroups, + }, + (res) => ({ data: res }), + ); + } + return Api.projects(searchString, { + ...commonParams, + membership: this.membership, + }); + })(); + projects = data.map((item) => ({ + text: item.name_with_namespace || item.name, + value: String(item.id), + })); + } catch (error) { + this.handleError({ message: FETCH_PROJECTS_ERROR, error }); + } + return { items: projects, totalPages: 1 }; + }, + async fetchProjectName(projectId) { + let projectName = ''; + try { + const { data: project } = await Api.project(projectId); + projectName = project.name_with_namespace; + } catch (error) { + this.handleError({ message: FETCH_PROJECT_ERROR, error }); + } + return projectName; + }, + handleError({ message, error }) { + Sentry.captureException(error); + this.errorMessage = message; + }, + dismissError() { + this.errorMessage = ''; + }, + }, + i18n: { + searchForProject: PROJECT_TOGGLE_TEXT, + selectProject: PROJECT_HEADER_TEXT, + }, +}; +</script> + +<template> + <entity-selector + :label="label" + :input-name="inputName" + :input-id="inputId" + :initial-selection="initialSelection" + :header-text="$options.i18n.selectProject" + :default-toggle-text="$options.i18n.searchForProject" + :fetch-items="fetchProjects" + :fetch-initial-selection-text="fetchProjectName" + clearable + > + <template v-if="hasHtmlLabel" #label> + <span v-safe-html="label"></span> + </template> + <template #error> + <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{ + errorMessage + }}</gl-alert> + </template> + </entity-selector> +</template> diff --git a/app/assets/javascripts/vue_shared/components/group_select/utils.js b/app/assets/javascripts/vue_shared/components/entity_select/utils.js index 0a4622269f4..0a4622269f4 100644 --- a/app/assets/javascripts/vue_shared/components/group_select/utils.js +++ b/app/assets/javascripts/vue_shared/components/entity_select/utils.js diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index adf34f822ed..6a10557c6bc 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -1,7 +1,7 @@ <script> +import { getIconForFile } from '@gitlab/svgs/src/file_icon_map'; import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { FILE_SYMLINK_MODE } from '../constants'; -import getIconForFile from './file_icon/file_icon_map'; /* This is a re-usable vue component for rendering a svg sprite icon @@ -88,7 +88,7 @@ export default { <gl-loading-icon v-if="loading" size="sm" :inline="true" /> <gl-icon v-else-if="isSymlink" name="symlink" :size="size" /> <svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]"> - <use v-bind="{ 'xlink:href': spriteHref }" /> + <use :href="spriteHref" /> </svg> <gl-icon v-else diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js deleted file mode 100644 index 8686d317c8a..00000000000 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ /dev/null @@ -1,610 +0,0 @@ -const fileExtensionIcons = { - html: 'html', - htm: 'html', - html_vm: 'html', - asp: 'html', - jade: 'pug', - pug: 'pug', - md: 'markdown', - markdown: 'markdown', - mdown: 'markdown', - mkd: 'markdown', - mkdn: 'markdown', - rst: 'markdown', - blink: 'blink', - css: 'css', - scss: 'sass', - sass: 'sass', - less: 'less', - json: 'json', - yaml: 'yaml', - yml: 'yaml', - xml: 'xml', - plist: 'xml', - xsd: 'xml', - dtd: 'xml', - xsl: 'xml', - xslt: 'xml', - resx: 'xml', - iml: 'xml', - xquery: 'xml', - tmLanguage: 'xml', - manifest: 'xml', - project: 'xml', - png: 'image', - jpeg: 'image', - jpg: 'image', - gif: 'image', - svg: 'image', - ico: 'image', - tif: 'image', - tiff: 'image', - psd: 'image', - psb: 'image', - ami: 'image', - apx: 'image', - bmp: 'image', - bpg: 'image', - brk: 'image', - cur: 'image', - dds: 'image', - dng: 'image', - exr: 'image', - fpx: 'image', - gbr: 'image', - img: 'image', - jbig2: 'image', - jb2: 'image', - jng: 'image', - jxr: 'image', - pbm: 'image', - pgf: 'image', - pic: 'image', - raw: 'image', - webp: 'image', - js: 'javascript', - ejs: 'javascript', - esx: 'javascript', - jsx: 'react', - tsx: 'react', - ini: 'settings', - dlc: 'settings', - dll: 'settings', - config: 'settings', - conf: 'settings', - properties: 'settings', - prop: 'settings', - settings: 'settings', - option: 'settings', - props: 'settings', - toml: 'settings', - prefs: 'settings', - ts: 'typescript', - marko: 'markojs', - pdf: 'pdf', - xlsx: 'table', - xls: 'table', - ods: 'table', - csv: 'table', - tsv: 'table', - vscodeignore: 'vscode', - vsixmanifest: 'vscode', - vsix: 'vscode', - suo: 'visualstudio', - sln: 'visualstudio', - csproj: 'visualstudio', - vb: 'visualstudio', - pdb: 'database', - sql: 'database', - pks: 'database', - pkb: 'database', - accdb: 'database', - mdb: 'database', - sqlite: 'database', - cs: 'csharp', - zip: 'zip', - tar: 'zip', - gz: 'zip', - xz: 'zip', - bzip2: 'zip', - gzip: 'zip', - rar: 'zip', - tgz: 'zip', - exe: 'exe', - msi: 'exe', - java: 'java', - jar: 'java', - jsp: 'java', - c: 'c', - m: 'c', - h: 'h', - cc: 'cpp', - cpp: 'cpp', - mm: 'cpp', - cxx: 'cpp', - hpp: 'hpp', - go: 'go', - py: 'python', - url: 'url', - sh: 'console', - ksh: 'console', - csh: 'console', - tcsh: 'console', - zsh: 'console', - bash: 'console', - bat: 'console', - cmd: 'console', - ps1: 'powershell', - psm1: 'powershell', - psd1: 'powershell', - ps1xml: 'powershell', - psc1: 'powershell', - pssc: 'powershell', - gradle: 'gradle', - doc: 'word', - docx: 'word', - odt: 'word', - rtf: 'word', - cer: 'certificate', - cert: 'certificate', - crt: 'certificate', - pub: 'key', - key: 'key', - pem: 'key', - asc: 'key', - gpg: 'key', - woff: 'font', - woff2: 'font', - ttf: 'font', - eot: 'font', - suit: 'font', - otf: 'font', - bmap: 'font', - fnt: 'font', - odttf: 'font', - ttc: 'font', - font: 'font', - fonts: 'font', - sui: 'font', - ntf: 'font', - mrf: 'font', - lib: 'lib', - bib: 'lib', - rb: 'ruby', - erb: 'ruby', - fs: 'fsharp', - fsx: 'fsharp', - fsi: 'fsharp', - fsproj: 'fsharp', - swift: 'swift', - ino: 'arduino', - dockerignore: 'docker', - dockerfile: 'docker', - tex: 'tex', - cls: 'tex', - sty: 'tex', - pptx: 'powerpoint', - ppt: 'powerpoint', - pptm: 'powerpoint', - potx: 'powerpoint', - pot: 'powerpoint', - potm: 'powerpoint', - ppsx: 'powerpoint', - ppsm: 'powerpoint', - pps: 'powerpoint', - ppam: 'powerpoint', - ppa: 'powerpoint', - odp: 'powerpoint', - webm: 'movie', - mkv: 'movie', - flv: 'movie', - vob: 'movie', - ogv: 'movie', - ogg: 'music', - gifv: 'movie', - avi: 'movie', - mov: 'movie', - qt: 'movie', - wmv: 'movie', - yuv: 'movie', - rm: 'movie', - rmvb: 'movie', - mp4: 'movie', - m4v: 'movie', - mpg: 'movie', - mp2: 'movie', - mpeg: 'movie', - mpe: 'movie', - mpv: 'movie', - m2v: 'movie', - vdi: 'virtual', - vbox: 'virtual', - ics: 'email', - mp3: 'music', - flac: 'music', - m4a: 'music', - wma: 'music', - aiff: 'music', - coffee: 'coffee', - txt: 'document', - graphql: 'graphql', - rs: 'rust', - raml: 'raml', - xaml: 'xaml', - hs: 'haskell', - kt: 'kotlin', - kts: 'kotlin', - patch: 'git', - lua: 'lua', - clj: 'clojure', - cljs: 'clojure', - groovy: 'groovy', - r: 'r', - rmd: 'r', - dart: 'dart', - as: 'actionscript', - mxml: 'mxml', - ahk: 'autohotkey', - swf: 'flash', - swc: 'swc', - cmake: 'cmake', - asm: 'assembly', - a51: 'assembly', - inc: 'assembly', - nasm: 'assembly', - s: 'assembly', - ms: 'assembly', - agc: 'assembly', - ags: 'assembly', - aea: 'assembly', - argus: 'assembly', - mitigus: 'assembly', - binsource: 'assembly', - vue: 'vue', - ml: 'ocaml', - mli: 'ocaml', - cmx: 'ocaml', - lock: 'lock', - hbs: 'handlebars', - mustache: 'handlebars', - pl: 'perl', - pm: 'perl', - hx: 'haxe', - pp: 'puppet', - ex: 'elixir', - exs: 'elixir', - ls: 'livescript', - erl: 'erlang', - twig: 'twig', - jl: 'julia', - elm: 'elm', - pure: 'purescript', - tpl: 'smarty', - styl: 'stylus', - re: 'reason', - rei: 'reason', - cmj: 'bucklescript', - merlin: 'merlin', - v: 'verilog', - vhd: 'verilog', - sv: 'verilog', - svh: 'verilog', - nb: 'mathematica', - wl: 'wolframlanguage', - wls: 'wolframlanguage', - njk: 'nunjucks', - nunjucks: 'nunjucks', - robot: 'robot', - sol: 'solidity', - au3: 'autoit', - haml: 'haml', - yang: 'yang', - tf: 'terraform', - tfvars: 'terraform', - tfstate: 'terraform', - applescript: 'applescript', - cake: 'cake', - feature: 'cucumber', - nim: 'nim', - nimble: 'nim', - apib: 'apiblueprint', - apiblueprint: 'apiblueprint', - tag: 'riot', - vfl: 'vfl', - kl: 'kl', - pcss: 'postcss', - sss: 'postcss', - todo: 'todo', - cfml: 'coldfusion', - cfc: 'coldfusion', - lucee: 'coldfusion', - cabal: 'cabal', - nix: 'nix', - slim: 'slim', - http: 'http', - rest: 'http', - rql: 'restql', - restql: 'restql', - kv: 'kivy', - graphcool: 'graphcool', - sbt: 'sbt', - cr: 'crystal', - cu: 'cuda', - cuh: 'cuda', - log: 'log', -}; - -const twoFileExtensionIcons = { - 'gradle.kts': 'gradle', - 'md.rendered': 'markdown', - 'markdown.rendered': 'markdown', - 'mdown.rendered': 'markdown', - 'mkd.rendered': 'markdown', - 'mkdn.rendered': 'markdown', - 'YAML-tmLanguage': 'yaml', - 'sln.dotsettings': 'settings', - 'sln.dotsettings.user': 'settings', - 'd.ts': 'typescript-def', - 'code-workplace': 'vscode', - '7z': 'zip', - 'c++': 'cpp', - 'vbox-prev': 'virtual', - 'js.map': 'javascript-map', - 'css.map': 'css-map', - 'spec.ts': 'test-ts', - 'test.ts': 'test-ts', - 'ts.snap': 'test-ts', - 'spec.tsx': 'test-jsx', - 'test.tsx': 'test-jsx', - 'tsx.snap': 'test-jsx', - 'spec.jsx': 'test-jsx', - 'test.jsx': 'test-jsx', - 'jsx.snap': 'test-jsx', - 'spec.js': 'test-js', - 'test.js': 'test-js', - 'js.snap': 'test-js', - 'routing.ts': 'angular-routing', - 'routing.js': 'angular-routing', - 'module.ts': 'angular', - 'module.js': 'angular', - 'ng-template': 'angular', - 'component.ts': 'angular-component', - 'component.js': 'angular-component', - 'guard.ts': 'angular-guard', - 'guard.js': 'angular-guard', - 'service.ts': 'angular-service', - 'service.js': 'angular-service', - 'pipe.ts': 'angular-pipe', - 'pipe.js': 'angular-pipe', - 'filter.js': 'angular-pipe', - 'directive.ts': 'angular-directive', - 'directive.js': 'angular-directive', - 'resolver.ts': 'angular-resolver', - 'resolver.js': 'angular-resolver', - 'tf.json': 'terraform', - 'blade.php': 'laravel', - 'inky.php': 'laravel', - 'reducer.ts': 'ngrx-reducer', - 'rootReducer.ts': 'ngrx-reducer', - 'state.ts': 'ngrx-state', - 'actions.ts': 'ngrx-actions', - 'effects.ts': 'ngrx-effects', - 'drone.yml': 'drone', -}; - -const fileNameIcons = { - '.jscsrc': 'json', - '.jshintrc': 'json', - 'tsconfig.json': 'json', - 'tslint.json': 'json', - 'composer.lock': 'json', - '.jsbeautifyrc': 'json', - '.esformatter': 'json', - 'cdp.pid': 'json', - '.htaccess': 'xml', - '.jshintignore': 'settings', - '.buildignore': 'settings', - makefile: 'settings', - '.mrconfig': 'settings', - '.yardopts': 'settings', - 'gradle.properties': 'gradle', - gradlew: 'gradle', - 'gradle-wrapper.properties': 'gradle', - COPYING: 'certificate', - 'COPYING.LESSER': 'certificate', - LICENSE: 'certificate', - LICENCE: 'certificate', - 'LICENSE.md': 'certificate', - 'LICENCE.md': 'certificate', - 'LICENSE.txt': 'certificate', - 'LICENCE.txt': 'certificate', - '.gitlab-license': 'certificate', - dockerfile: 'docker', - 'docker-compose.yml': 'docker', - '.mailmap': 'email', - '.gitignore': 'git', - '.gitconfig': 'git', - '.gitattributes': 'git', - '.gitmodules': 'git', - '.gitkeep': 'git', - 'git-history': 'git', - '.Rhistory': 'r', - 'cmakelists.txt': 'cmake', - 'cmakecache.txt': 'cmake', - 'angular-cli.json': 'angular', - '.angular-cli.json': 'angular', - '.vfl': 'vfl', - '.kl': 'kl', - 'postcss.config.js': 'postcss', - '.postcssrc.js': 'postcss', - 'project.graphcool': 'graphcool', - 'webpack.js': 'webpack', - 'webpack.ts': 'webpack', - 'webpack.base.js': 'webpack', - 'webpack.base.ts': 'webpack', - 'webpack.config.js': 'webpack', - 'webpack.config.ts': 'webpack', - 'webpack.common.js': 'webpack', - 'webpack.common.ts': 'webpack', - 'webpack.config.common.js': 'webpack', - 'webpack.config.common.ts': 'webpack', - 'webpack.config.common.babel.js': 'webpack', - 'webpack.config.common.babel.ts': 'webpack', - 'webpack.dev.js': 'webpack', - 'webpack.dev.ts': 'webpack', - 'webpack.config.dev.js': 'webpack', - 'webpack.config.dev.ts': 'webpack', - 'webpack.config.dev.babel.js': 'webpack', - 'webpack.config.dev.babel.ts': 'webpack', - 'webpack.prod.js': 'webpack', - 'webpack.prod.ts': 'webpack', - 'webpack.server.js': 'webpack', - 'webpack.server.ts': 'webpack', - 'webpack.client.js': 'webpack', - 'webpack.client.ts': 'webpack', - 'webpack.config.server.js': 'webpack', - 'webpack.config.server.ts': 'webpack', - 'webpack.config.client.js': 'webpack', - 'webpack.config.client.ts': 'webpack', - 'webpack.config.production.babel.js': 'webpack', - 'webpack.config.production.babel.ts': 'webpack', - 'webpack.config.prod.babel.js': 'webpack', - 'webpack.config.prod.babel.ts': 'webpack', - 'webpack.config.prod.js': 'webpack', - 'webpack.config.prod.ts': 'webpack', - 'webpack.config.production.js': 'webpack', - 'webpack.config.production.ts': 'webpack', - 'webpack.config.staging.js': 'webpack', - 'webpack.config.staging.ts': 'webpack', - 'webpack.config.babel.js': 'webpack', - 'webpack.config.babel.ts': 'webpack', - 'webpack.config.base.babel.js': 'webpack', - 'webpack.config.base.babel.ts': 'webpack', - 'webpack.config.base.js': 'webpack', - 'webpack.config.base.ts': 'webpack', - 'webpack.config.staging.babel.js': 'webpack', - 'webpack.config.staging.babel.ts': 'webpack', - 'webpack.config.coffee': 'webpack', - 'webpack.config.test.js': 'webpack', - 'webpack.config.test.ts': 'webpack', - 'webpack.config.vendor.js': 'webpack', - 'webpack.config.vendor.ts': 'webpack', - 'webpack.config.vendor.production.js': 'webpack', - 'webpack.config.vendor.production.ts': 'webpack', - 'webpack.test.js': 'webpack', - 'webpack.test.ts': 'webpack', - 'webpack.dist.js': 'webpack', - 'webpack.dist.ts': 'webpack', - 'webpackfile.js': 'webpack', - 'webpackfile.ts': 'webpack', - 'ionic.config.json': 'ionic', - '.io-config.json': 'ionic', - 'gulpfile.js': 'gulp', - 'gulpfile.ts': 'gulp', - 'gulpfile.babel.js': 'gulp', - 'package.json': 'nodejs', - 'package-lock.json': 'nodejs', - '.nvmrc': 'nodejs', - '.npmignore': 'npm', - '.npmrc': 'npm', - '.yarnrc': 'yarn', - '.yarnrc.yml': 'yarn', - 'yarn.lock': 'yarn', - '.yarnclean': 'yarn', - '.yarn-integrity': 'yarn', - 'yarn-error.log': 'yarn', - 'androidmanifest.xml': 'android', - '.env': 'tune', - '.env.example': 'tune', - '.babelrc': 'babel', - 'contributing.md': 'contributing', - 'contributing.md.rendered': 'contributing', - 'readme.md': 'readme', - 'readme.md.rendered': 'readme', - changelog: 'changelog', - 'changelog.md': 'changelog', - 'changelog.md.rendered': 'changelog', - CREDITS: 'credits', - 'credits.txt': 'credits', - 'credits.md': 'credits', - 'credits.md.rendered': 'credits', - '.flowconfig': 'flow', - 'favicon.png': 'favicon', - 'karma.conf.js': 'karma', - 'karma.conf.ts': 'karma', - 'karma.conf.coffee': 'karma', - 'karma.config.js': 'karma', - 'karma.config.ts': 'karma', - 'karma-main.js': 'karma', - 'karma-main.ts': 'karma', - '.bithoundrc': 'bithound', - 'appveyor.yml': 'appveyor', - '.travis.yml': 'travis', - 'protractor.conf.js': 'protractor', - 'protractor.conf.ts': 'protractor', - 'protractor.conf.coffee': 'protractor', - 'protractor.config.js': 'protractor', - 'protractor.config.ts': 'protractor', - 'fuse.js': 'fusebox', - procfile: 'heroku', - '.editorconfig': 'editorconfig', - '.gitlab-ci.yml': 'gitlab', - '.bowerrc': 'bower', - 'bower.json': 'bower', - '.eslintrc.js': 'eslint', - '.eslintrc.yaml': 'eslint', - '.eslintrc.yml': 'eslint', - '.eslintrc.json': 'eslint', - '.eslintrc': 'eslint', - '.eslintignore': 'eslint', - 'code_of_conduct.md': 'conduct', - 'code_of_conduct.md.rendered': 'conduct', - '.watchmanconfig': 'watchman', - 'aurelia.json': 'aurelia', - 'mocha.opts': 'mocha', - jenkinsfile: 'jenkins', - 'firebase.json': 'firebase', - '.firebaserc': 'firebase', - Rakefile: 'ruby', - 'rollup.config.js': 'rollup', - 'rollup.config.ts': 'rollup', - 'rollup-config.js': 'rollup', - 'rollup-config.ts': 'rollup', - 'rollup.config.prod.js': 'rollup', - 'rollup.config.prod.ts': 'rollup', - 'rollup.config.dev.js': 'rollup', - 'rollup.config.dev.ts': 'rollup', - 'rollup.config.prod.vendor.js': 'rollup', - 'rollup.config.prod.vendor.ts': 'rollup', - '.hhconfig': 'hack', - '.stylelintrc': 'stylelint', - 'stylelint.config.js': 'stylelint', - '.stylelintrc.json': 'stylelint', - '.stylelintrc.yaml': 'stylelint', - '.stylelintrc.yml': 'stylelint', - '.stylelintrc.js': 'stylelint', - '.stylelintignore': 'stylelint', - '.codeclimate.yml': 'code-climate', - '.prettierrc': 'prettier', - 'prettier.config.js': 'prettier', - '.prettierrc.js': 'prettier', - '.prettierrc.json': 'prettier', - '.prettierrc.yaml': 'prettier', - '.prettierrc.yml': 'prettier', - '.prettierignore': 'prettier', - 'nodemon.json': 'nodemon', - '.sonarrc': 'sonar', - browserslist: 'browserlist', - '.browserslistrc': 'browserlist', - '.snyk': 'snyk', - '.drone.yml': 'drone', -}; - -export default function getIconForFile(name) { - return ( - fileNameIcons[name] || - twoFileExtensionIcons[name ? name.split('.').slice(-2).join('.') : ''] || - fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] || - '' - ); -} diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 8a3a174f414..dfeb12d5cf5 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -43,11 +43,6 @@ export default { isBlob() { return this.file.type === 'blob'; }, - levelIndentation() { - return { - marginLeft: this.level ? `${this.level * 8}px` : null, - }; - }, fileClass() { return { 'file-open': this.isBlob && this.file.opened, @@ -144,7 +139,6 @@ export default { > <span ref="textOutput" - :style="levelIndentation" class="file-row-name" :title="file.name" data-qa-selector="file_name_content" @@ -198,6 +192,7 @@ export default { line-height: 16px; text-overflow: ellipsis; white-space: nowrap; + margin-left: calc(var(--level) * 16px); } .file-row-name .file-row-icon { diff --git a/app/assets/javascripts/vue_shared/components/file_tree.vue b/app/assets/javascripts/vue_shared/components/file_tree.vue index e7817b8f910..2e0cdbb12f9 100644 --- a/app/assets/javascripts/vue_shared/components/file_tree.vue +++ b/app/assets/javascripts/vue_shared/components/file_tree.vue @@ -20,11 +20,16 @@ export default { return this.file.isHeader ? 0 : this.level + 1; }, }, + methods: { + hasChildren(childFile) { + return childFile.tree?.length; + }, + }, }; </script> <template> - <div> + <div :style="{ '--level': level }"> <component :is="fileRowComponent" :level="level" @@ -39,6 +44,8 @@ export default { :file-row-component="fileRowComponent" :level="childFilesLevel" :file="childFile" + :class="{ 'tree-list-parent': hasChildren(childFile) }" + class="gl-relative" v-bind="$attrs" v-on="$listeners" /> 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 993b4c11c0e..5b98af8c732 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 @@ -50,6 +50,7 @@ export const TOKEN_TITLE_ASSIGNEE = s__('SearchToken|Assignee'); export const TOKEN_TITLE_AUTHOR = __('Author'); export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); export const TOKEN_TITLE_CONTACT = s__('Crm|Contact'); +export const TOKEN_TITLE_GROUP = __('Group'); export const TOKEN_TITLE_LABEL = __('Label'); export const TOKEN_TITLE_MILESTONE = __('Milestone'); export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); @@ -67,6 +68,7 @@ export const TOKEN_TYPE_ASSIGNEE = 'assignee'; export const TOKEN_TYPE_AUTHOR = 'author'; export const TOKEN_TYPE_CONFIDENTIAL = 'confidential'; export const TOKEN_TYPE_CONTACT = 'contact'; +export const TOKEN_TYPE_GROUP = 'group'; export const TOKEN_TYPE_EPIC = 'epic'; // As health status gets reused between issue lists and boards // this is in the shared constants. Until we have not decoupled the EE filtered search bar @@ -85,5 +87,4 @@ export const TOKEN_TYPE_STATUS = 'status'; export const TOKEN_TYPE_TARGET_BRANCH = 'target-branch'; export const TOKEN_TYPE_TYPE = 'type'; export const TOKEN_TYPE_WEIGHT = 'weight'; - export const TOKEN_TYPE_SEARCH_WITHIN = 'in'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue index e0fa06c159e..c8aeac75645 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue @@ -2,6 +2,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { ITEM_TYPE } from '~/groups/constants'; +import { TYPENAME_CRM_CONTACT } from '~/graphql_shared/constants'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/flash'; import { isPositiveInteger } from '~/lib/utils/number_utils'; @@ -93,7 +94,7 @@ export default { return `${getIdFromGraphQLId(contact.id)}`; }, formatContactGraphQLId(id) { - return convertToGraphQLId('CustomerRelations::Contact', id); + return convertToGraphQLId(TYPENAME_CRM_CONTACT, id); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue index 3f030c8698c..ff0571031b5 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue @@ -2,6 +2,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { ITEM_TYPE } from '~/groups/constants'; +import { TYPENAME_CRM_ORGANIZATION } from '~/graphql_shared/constants'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/flash'; import { isPositiveInteger } from '~/lib/utils/number_utils'; @@ -90,7 +91,7 @@ export default { return `${getIdFromGraphQLId(organization.id)}`; }, formatOrganizationGraphQLId(id) { - return convertToGraphQLId('CustomerRelations::Organization', id); + return convertToGraphQLId(TYPENAME_CRM_ORGANIZATION, id); }, }, }; 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 71c50ef292a..9449e071a0d 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 @@ -79,6 +79,9 @@ export default { // labels.json and /groups/:id/labels & /projects/:id/labels // return response differently. this.labels = Array.isArray(res) ? res : res.data; + if (this.config.fetchLatestLabels) { + this.fetchLatestLabels(searchTerm); + } }) .catch(() => createAlert({ @@ -89,6 +92,21 @@ export default { this.loading = false; }); }, + fetchLatestLabels(searchTerm) { + this.config + .fetchLatestLabels(searchTerm) + .then((res) => { + // We'd want to avoid doing this check but + // labels.json and /groups/:id/labels & /projects/:id/labels + // return response differently. + this.labels = Array.isArray(res) ? res : res.data; + }) + .catch(() => + createAlert({ + message: __('There was a problem fetching latest labels.'), + }), + ); + }, }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/group_select/constants.js b/app/assets/javascripts/vue_shared/components/group_select/constants.js deleted file mode 100644 index 06537d682fe..00000000000 --- a/app/assets/javascripts/vue_shared/components/group_select/constants.js +++ /dev/null @@ -1,7 +0,0 @@ -import { __ } from '~/locale'; - -export const TOGGLE_TEXT = __('Search for a group'); -export const RESET_LABEL = __('Reset'); -export const FETCH_GROUPS_ERROR = __('Unable to fetch groups. Reload the page to try again.'); -export const FETCH_GROUP_ERROR = __('Unable to fetch group. Reload the page to try again.'); -export const QUERY_TOO_SHORT_MESSAGE = __('Enter at least three characters to search.'); diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 8e459cc21ac..28baabbdb81 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -4,7 +4,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { glEmojiTag } from '~/emoji'; import { __, sprintf } from '~/locale'; -import CiIconBadge from './ci_badge_link.vue'; +import CiBadgeLink from './ci_badge_link.vue'; import TimeagoTooltip from './time_ago_tooltip.vue'; /** @@ -16,7 +16,7 @@ import TimeagoTooltip from './time_ago_tooltip.vue'; */ export default { components: { - CiIconBadge, + CiBadgeLink, TimeagoTooltip, GlButton, GlAvatarLink, @@ -120,7 +120,7 @@ export default { data-testid="ci-header-content" > <section class="header-main-content gl-mr-3"> - <ci-icon-badge :status="status" /> + <ci-badge-link class="gl-mr-3" :status="status" /> <strong data-testid="ci-header-item-text">{{ item }}</strong> diff --git a/app/assets/javascripts/vue_shared/components/incubation/incubation_alert.vue b/app/assets/javascripts/vue_shared/components/incubation/incubation_alert.vue new file mode 100644 index 00000000000..b704cec2475 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/incubation/incubation_alert.vue @@ -0,0 +1,61 @@ +<script> +import { GlAlert, GlLink } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'IncubationAlert', + components: { GlAlert, GlLink }, + props: { + featureName: { + type: String, + required: true, + }, + linkToFeedbackIssue: { + type: String, + required: true, + }, + }, + data() { + return { + isAlertDismissed: false, + }; + }, + computed: { + shouldShowAlert() { + return !this.isAlertDismissed; + }, + titleLabel() { + return sprintf(this.$options.i18n.titleLabel, { featureName: this.featureName }); + }, + }, + methods: { + dismissAlert() { + this.isAlertDismissed = true; + }, + }, + i18n: { + titleLabel: s__('Incubation|%{featureName} is in incubating phase'), + contentLabel: s__( + 'Incubation|GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited.', + ), + learnMoreLabel: s__('Incubation|Learn more about incubating features'), + feedbackLabel: s__('Incubation|Give feedback on this feature'), + }, +}; +</script> + +<template> + <gl-alert + v-if="shouldShowAlert" + :title="titleLabel" + variant="warning" + :primary-button-text="$options.i18n.feedbackLabel" + :primary-button-link="linkToFeedbackIssue" + @dismiss="dismissAlert" + > + {{ $options.i18n.contentLabel }} + <gl-link href="https://about.gitlab.com/handbook/engineering/incubation/" target="_blank">{{ + $options.i18n.learnMoreLabel + }}</gl-link> + </gl-alert> +</template> diff --git a/app/assets/javascripts/vue_shared/components/incubation/pagination.vue b/app/assets/javascripts/vue_shared/components/incubation/pagination.vue new file mode 100644 index 00000000000..b5afe92316a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/incubation/pagination.vue @@ -0,0 +1,62 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; + +export default { + name: 'KeysetPagination', + components: { + GlKeysetPagination, + }, + props: { + startCursor: { + type: String, + required: false, + default: '', + }, + endCursor: { + type: String, + required: false, + default: '', + }, + hasNextPage: { + type: Boolean, + required: true, + }, + hasPreviousPage: { + type: Boolean, + required: true, + }, + }, + computed: { + previousPageLink() { + return setUrlParams({ cursor: this.startCursor }); + }, + nextPageLink() { + return setUrlParams({ cursor: this.endCursor }); + }, + isPaginationVisible() { + return this.hasPreviousPage || this.hasNextPage; + }, + }, + i18n: { + previousPageButtonLabel: __('Prev'), + nextPageButtonLabel: __('Next'), + }, +}; +</script> + +<template> + <div v-if="isPaginationVisible" class="gl--flex-center"> + <gl-keyset-pagination + :start-cursor="startCursor" + :end-cursor="endCursor" + :has-previous-page="hasPreviousPage" + :has-next-page="hasNextPage" + :prev-text="$options.i18n.previousPageButtonLabel" + :next-text="$options.i18n.nextPageButtonLabel" + :prev-button-link="previousPageLink" + :next-button-link="nextPageLink" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js index d80c1ff8b0c..9a88ab44f3d 100644 --- a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js @@ -1,9 +1,10 @@ import { issuableTypes } from '~/boards/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import blockingIssuesQuery from './graphql/blocking_issues.query.graphql'; import blockingEpicsQuery from './graphql/blocking_epics.query.graphql'; export const blockingIssuablesQueries = { - [issuableTypes.issue]: { + [TYPE_ISSUE]: { query: blockingIssuesQuery, }, [issuableTypes.epic]: { diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue index 253aca8837d..f5b4870d59f 100644 --- a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue @@ -1,8 +1,9 @@ <script> import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui'; import { issuableTypes } from '~/boards/constants'; -import { TYPE_ISSUE, TYPE_EPIC } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE, TYPENAME_EPIC } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/issues/constants'; import { truncate } from '~/lib/utils/text_utility'; import { __, n__, s__, sprintf } from '~/locale'; import { blockingIssuablesQueries } from './constants'; @@ -10,16 +11,16 @@ import { blockingIssuablesQueries } from './constants'; export default { i18n: { issuableType: { - [issuableTypes.issue]: __('issue'), + [TYPE_ISSUE]: __('issue'), [issuableTypes.epic]: __('epic'), }, }, graphQLIdType: { - [issuableTypes.issue]: TYPE_ISSUE, - [issuableTypes.epic]: TYPE_EPIC, + [TYPE_ISSUE]: TYPENAME_ISSUE, + [issuableTypes.epic]: TYPENAME_EPIC, }, referenceFormatter: { - [issuableTypes.issue]: (r) => r.split('/')[1], + [TYPE_ISSUE]: (r) => r.split('/')[1], }, defaultDisplayLimit: 3, textTruncateWidth: 80, @@ -42,7 +43,7 @@ export default { type: String, required: true, validator(value) { - return [issuableTypes.issue, issuableTypes.epic].includes(value); + return [TYPE_ISSUE, issuableTypes.epic].includes(value); }, }, }, @@ -119,7 +120,7 @@ export default { ); }, blockIcon() { - return this.issuableType === issuableTypes.issue ? 'issue-block' : 'entity-blocked'; + return this.issuableType === TYPE_ISSUE ? 'issue-block' : 'entity-blocked'; }, glIconId() { return `blocked-icon-${this.uniqueId}`; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 7b76fc3fc6d..6f4cddbdfa2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -82,6 +82,11 @@ export default { required: false, default: true, }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, line: { type: Object, required: false, @@ -257,6 +262,7 @@ export default { contacts: this.enableAutocomplete, }, true, + this.autocompleteDataSources, ); }, beforeDestroy() { diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index c53118b9f62..7e6b0e4a63b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -41,33 +41,25 @@ export default { required: false, default: true, }, - formFieldId: { - type: String, - required: true, - }, - formFieldName: { - type: String, - required: true, - }, enablePreview: { type: Boolean, required: false, default: true, }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, enableAutocomplete: { type: Boolean, required: false, default: true, }, - formFieldPlaceholder: { - type: String, - required: false, - default: '', - }, - formFieldAriaLabel: { - type: String, - required: false, - default: '', + formFieldProps: { + type: Object, + required: true, + validator: (prop) => prop.id && prop.name, }, autofocus: { type: Boolean, @@ -152,6 +144,7 @@ export default { :textarea-value="value" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" + :autocomplete-data-sources="autocompleteDataSources" :uploads-path="uploadsPath" :enable-preview="enablePreview" show-content-editor-switcher @@ -160,16 +153,13 @@ export default { > <template #textarea> <textarea - :id="formFieldId" + v-bind="formFieldProps" ref="textarea" :value="value" - :name="formFieldName" class="note-textarea js-gfm-input js-autosize markdown-area" dir="auto" :data-supports-quick-actions="supportsQuickActions" data-qa-selector="markdown_editor_form_field" - :aria-label="formFieldAriaLabel" - :placeholder="formFieldPlaceholder" @input="updateMarkdownFromMarkdownField" @keydown="$emit('keydown', $event)" > @@ -189,9 +179,8 @@ export default { @enableMarkdownEditor="onEditingModeChange('markdownField')" /> <input - :id="formFieldId" + v-bind="formFieldProps" :value="value" - :name="formFieldName" data-qa-selector="markdown_editor_form_field" type="hidden" /> diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/constants.js b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/constants.js new file mode 100644 index 00000000000..e5dca170965 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/constants.js @@ -0,0 +1,26 @@ +import { __ } from '~/locale'; + +export const RESOURCE_TYPE_ISSUE = 'issue'; +export const RESOURCE_TYPE_MERGE_REQUEST = 'merge-request'; +export const RESOURCE_TYPE_MILESTONE = 'milestone'; + +export const RESOURCE_TYPES = [ + RESOURCE_TYPE_ISSUE, + RESOURCE_TYPE_MERGE_REQUEST, + RESOURCE_TYPE_MILESTONE, +]; + +export const RESOURCE_OPTIONS = { + [RESOURCE_TYPE_ISSUE]: { + path: 'issues/new', + label: __('issue'), + }, + [RESOURCE_TYPE_MERGE_REQUEST]: { + path: 'merge_requests/new', + label: __('merge request'), + }, + [RESOURCE_TYPE_MILESTONE]: { + path: 'milestones/new', + label: __('milestone'), + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql new file mode 100644 index 00000000000..578914dbbaf --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql @@ -0,0 +1,18 @@ +query searchUserGroupProjectsWithMergeRequestsEnabled($fullPath: ID!, $search: String) { + group(fullPath: $fullPath) { + id + projects( + search: $search + withMergeRequestsEnabled: true + includeSubgroups: true + sort: ACTIVITY_DESC + ) { + nodes { + id + name + nameWithNamespace + webUrl + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql new file mode 100644 index 00000000000..8fe92cf7c6c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql @@ -0,0 +1,21 @@ +query searchUserGroupsAndProjects($username: String!, $search: String) { + projects(sort: "latest_activity_desc", membership: true) { + nodes { + id + name + nameWithNamespace + webUrl + } + } + + user(username: $username) { + id + groups(search: $search) { + nodes { + id + name + webUrl + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql new file mode 100644 index 00000000000..a630c885d28 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql @@ -0,0 +1,15 @@ +query searchUserProjectsWithIssuesEnabled($search: String) { + projects( + search: $search + membership: true + withIssuesEnabled: true + sort: "latest_activity_desc" + ) { + nodes { + id + name + nameWithNamespace + webUrl + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql new file mode 100644 index 00000000000..44ebf755728 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql @@ -0,0 +1,15 @@ +query searchUserProjectsWithMergeRequestsEnabled($search: String) { + projects( + search: $search + membership: true + withMergeRequestsEnabled: true + sort: "latest_activity_desc" + ) { + nodes { + id + name + nameWithNamespace + webUrl + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown.js b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown.js new file mode 100644 index 00000000000..f3905dabedd --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown.js @@ -0,0 +1,46 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import NewResourceDropdown from './new_resource_dropdown.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const initNewResourceDropdown = (props = {}) => { + const el = document.querySelector('.js-new-resource-dropdown'); + + if (!el) { + return false; + } + + const { groupId, fullPath, username } = el.dataset; + + return new Vue({ + el, + apolloProvider, + render(createElement) { + return createElement(NewResourceDropdown, { + props: { + withLocalStorage: true, + groupId, + queryVariables: { + ...(fullPath + ? { + fullPath, + } + : {}), + ...(username + ? { + username, + } + : {}), + }, + ...props, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue new file mode 100644 index 00000000000..b079181bd10 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue @@ -0,0 +1,208 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLoadingIcon, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; +import { __, sprintf } from '~/locale'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import AccessorUtilities from '~/lib/utils/accessor'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import searchUserProjectsWithIssuesEnabled from './graphql/search_user_projects_with_issues_enabled.query.graphql'; +import { RESOURCE_TYPE_ISSUE, RESOURCE_TYPES, RESOURCE_OPTIONS } from './constants'; + +export default { + i18n: { + noMatchesFound: __('No matches found'), + toggleButtonLabel: __('Toggle project select'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLoadingIcon, + GlSearchBoxByType, + LocalStorageSync, + }, + props: { + resourceType: { + type: String, + required: false, + default: RESOURCE_TYPE_ISSUE, + validator: (value) => RESOURCE_TYPES.includes(value), + }, + query: { + type: Object, + required: false, + default: () => searchUserProjectsWithIssuesEnabled, + }, + groupId: { + type: String, + required: false, + default: '', + }, + queryVariables: { + type: Object, + required: false, + default: () => ({}), + }, + extractProjects: { + type: Function, + required: false, + default: (data) => data?.projects?.nodes, + }, + withLocalStorage: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + projects: [], + search: '', + selectedProject: {}, + shouldSkipQuery: true, + }; + }, + apollo: { + projects: { + query() { + return this.query; + }, + variables() { + return { + search: this.search, + ...this.queryVariables, + }; + }, + update(data) { + return this.extractProjects(data) || []; + }, + error(error) { + createAlert({ + message: __('An error occurred while loading projects.'), + captureError: true, + error, + }); + }, + skip() { + return this.shouldSkipQuery; + }, + debounce: DEBOUNCE_DELAY, + }, + }, + computed: { + localStorageKey() { + return `group-${this.groupId}-new-${this.resourceType}-recent-project`; + }, + resourceOptions() { + return RESOURCE_OPTIONS[this.resourceType]; + }, + defaultDropdownText() { + return sprintf(__('Select project to create %{type}'), { type: this.resourceOptions.label }); + }, + dropdownHref() { + return this.hasSelectedProject + ? joinPaths(this.selectedProject.webUrl, DASH_SCOPE, this.resourceOptions.path) + : undefined; + }, + dropdownText() { + return this.hasSelectedProject + ? sprintf(__('New %{type} in %{project}'), { + type: this.resourceOptions.label, + project: this.selectedProject.name, + }) + : this.defaultDropdownText; + }, + hasSelectedProject() { + return this.selectedProject.webUrl; + }, + showNoSearchResultsText() { + return !this.projects.length && this.search; + }, + canUseLocalStorage() { + return this.withLocalStorage && AccessorUtilities.canUseLocalStorage(); + }, + selectedProjectForLocalStorage() { + const { webUrl, name } = this.selectedProject; + + return { webUrl, name }; + }, + }, + methods: { + handleDropdownClick() { + if (!this.dropdownHref) { + this.$refs.dropdown.show(); + } + }, + handleDropdownShown() { + if (this.shouldSkipQuery) { + this.shouldSkipQuery = false; + } + this.$refs.search.focusInput(); + }, + selectProject(project) { + this.selectedProject = project; + }, + initFromLocalStorage(storedProject) { + // Historically, the selected project was saved with the URL as the `url` property, so we are + // falling back to that legacy property if `webUrl` is empty. This ensures that we support + // localStorage data that was persisted prior to this change. + let webUrl = storedProject.webUrl || storedProject.url; + + // The select2 implementation used to include the resource path in the local storage. We + // need to clean this up so that we can then re-build a fresh URL in the computed prop. + webUrl = webUrl.endsWith(this.resourceOptions.path) + ? webUrl.slice(0, webUrl.length - this.resourceOptions.path.length) + : webUrl; + const dashSuffix = `${DASH_SCOPE}/`; + webUrl = webUrl.endsWith(dashSuffix) + ? webUrl.slice(0, webUrl.length - dashSuffix.length) + : webUrl; + + this.selectedProject = { webUrl, name: storedProject.name }; + }, + }, +}; +</script> + +<template> + <local-storage-sync + :storage-key="localStorageKey" + :value="selectedProjectForLocalStorage" + @input="initFromLocalStorage" + > + <gl-dropdown + ref="dropdown" + right + split + :split-href="dropdownHref" + :text="dropdownText" + :toggle-text="$options.i18n.toggleButtonLabel" + variant="confirm" + data-testid="new-resource-dropdown" + @click="handleDropdownClick" + @shown="handleDropdownShown" + > + <gl-search-box-by-type ref="search" v-model.trim="search" /> + <gl-loading-icon v-if="$apollo.queries.projects.loading" /> + <template v-else> + <gl-dropdown-item + v-for="project of projects" + :key="project.id" + @click="selectProject(project)" + > + {{ project.nameWithNamespace || project.name }} + </gl-dropdown-item> + <gl-dropdown-text v-if="showNoSearchResultsText"> + {{ $options.i18n.noMatchesFound }} + </gl-dropdown-text> + </template> + </gl-dropdown> + </local-storage-sync> +</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 5516c9943b8..5d0ee6adffe 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -33,6 +33,7 @@ export default { 'gl-border-t-transparent': !this.first && !this.selected, 'gl-border-t-gray-100': this.first && !this.selected, 'gl-border-b-gray-100': !this.selected, + 'gl-border-t-transparent!': this.selected && !this.first, 'gl-bg-blue-50 gl-border-blue-200': this.selected, }; }, @@ -126,10 +127,9 @@ export default { <slot name="right-action"></slot> </div> </div> - <div class="gl-display-flex"> + <div v-if="isDetailsShown" class="gl-display-flex"> <div class="gl-w-7"></div> <div - v-if="isDetailsShown" class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100 gl-mb-3" > <div diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue deleted file mode 100644 index e3e3b9abc3c..00000000000 --- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue +++ /dev/null @@ -1,43 +0,0 @@ -<script> -import { GlButton, GlModalDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import RunnerAwsDeploymentsModal from './runner_aws_deployments_modal.vue'; - -export default { - components: { - GlButton, - RunnerAwsDeploymentsModal, - }, - directives: { - GlModalDirective, - }, - modalId: 'runner-aws-deployments-modal', - i18n: { - buttonText: s__('Runners|Deploy GitLab Runner in AWS'), - }, - data() { - return { - opened: false, - }; - }, - methods: { - onClick() { - this.opened = true; - }, - }, -}; -</script> -<template> - <div> - <gl-button - v-gl-modal-directive="$options.modalId" - class="gl-mt-4" - data-testid="show-modal-button" - variant="confirm" - @click="onClick" - > - {{ $options.i18n.buttonText }} - </gl-button> - <runner-aws-deployments-modal v-if="opened" :modal-id="$options.modalId" /> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue deleted file mode 100644 index 08acde1aefc..00000000000 --- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue +++ /dev/null @@ -1,29 +0,0 @@ -<script> -import { GlModal } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue'; - -export default { - components: { - GlModal, - RunnerAwsInstructions, - }, - props: { - modalId: { - type: String, - required: true, - }, - }, - methods: { - onClose() { - this.$refs.modal.close(); - }, - }, - i18n_title: s__('Runners|Deploy GitLab Runner in AWS'), -}; -</script> -<template> - <gl-modal ref="modal" :modal-id="modalId" :title="$options.i18n_title" hide-footer size="sm"> - <runner-aws-instructions @close="onClose" /> - </gl-modal> -</template> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js index 3dbc5246c3d..b66c89d1372 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js @@ -4,6 +4,7 @@ export const REGISTRATION_TOKEN_PLACEHOLDER = '$REGISTRATION_TOKEN'; export const PLATFORM_DOCKER = 'docker'; export const PLATFORM_KUBERNETES = 'kubernetes'; +export const PLATFORM_AWS = 'aws'; export const AWS_README_URL = 'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md'; diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue index cafebdfe5f4..8a234889e6f 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue @@ -2,6 +2,7 @@ import { GlButton, GlSprintf, + GlIcon, GlLink, GlFormRadioGroup, GlFormRadio, @@ -11,6 +12,7 @@ import { import Tracking from '~/tracking'; import { getBaseURL, objectToQuery, visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import { AWS_README_URL, AWS_CF_BASE_URL, @@ -22,13 +24,22 @@ export default { components: { GlButton, GlSprintf, + GlIcon, GlLink, GlFormRadioGroup, GlFormRadio, GlAccordion, GlAccordionItem, + ModalCopyButton, }, mixins: [Tracking.mixin()], + props: { + registrationToken: { + type: String, + required: false, + default: null, + }, + }, data() { return { selectedIndex: 0, @@ -65,16 +76,20 @@ export default { }, }, i18n: { - title: s__('Runners|Deploy GitLab Runner in AWS'), instructions: s__( - 'Runners|Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console.', + 'Runners|Select your preferred runner, then choose the capacity for the runner in the AWS CloudFormation console.', ), chooseRunner: s__('Runners|Choose your preferred GitLab Runner'), dontSeeWhatYouAreLookingFor: s__( "Runners|Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}.", ), + runnerRegistrationToken: s__('Runners|Runner Registration token'), + copyInstructions: s__('Runners|Copy registration token'), moreDetails: __('More Details'), lessDetails: __('Less Details'), + close: __('Close'), + deployRunnerInAws: s__('Runners|Deploy GitLab Runner in AWS'), + externalLink: __('(external link)'), }, readmeUrl: AWS_README_URL, easyButtons: AWS_EASY_BUTTONS, @@ -83,6 +98,7 @@ export default { <template> <div> <p>{{ $options.i18n.instructions }}</p> + <gl-form-radio-group v-model="selectedIndex" :label="$options.i18n.chooseRunner" label-sr-only> <gl-form-radio v-for="(easyButton, idx) in $options.easyButtons" @@ -113,10 +129,23 @@ export default { </template> </gl-sprintf> </p> + <template v-if="registrationToken"> + <h5 class="gl-mb-3">{{ $options.i18n.runnerRegistrationToken }}</h5> + <div class="gl-display-flex"> + <pre class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line">{{ registrationToken }}</pre> + <modal-copy-button + :title="$options.i18n.copyInstructions" + :text="registrationToken" + css-classes="gl-align-self-start gl-ml-2 gl-mt-2" + category="tertiary" + /> + </div> + </template> <footer class="gl-display-flex gl-justify-content-end gl-pt-3 gl-gap-3"> - <gl-button @click="onClose()">{{ __('Close') }}</gl-button> + <gl-button @click="onClose()">{{ $options.i18n.close }}</gl-button> <gl-button variant="confirm" @click="onOk()"> - {{ s__('Runners|Deploy GitLab Runner in AWS') }} + {{ $options.i18n.deployRunnerInAws }} + <gl-icon name="external-link" :aria-label="$options.i18n.externalLink" /> </gl-button> </footer> </div> 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 729fe9c462c..22d9b88fa41 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 @@ -14,11 +14,12 @@ import { import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { __, s__ } from '~/locale'; import getRunnerPlatformsQuery from './graphql/get_runner_platforms.query.graphql'; -import { PLATFORM_DOCKER, PLATFORM_KUBERNETES } from './constants'; +import { PLATFORM_DOCKER, PLATFORM_KUBERNETES, PLATFORM_AWS } from './constants'; import RunnerCliInstructions from './instructions/runner_cli_instructions.vue'; import RunnerDockerInstructions from './instructions/runner_docker_instructions.vue'; import RunnerKubernetesInstructions from './instructions/runner_kubernetes_instructions.vue'; +import RunnerAwsInstructions from './instructions/runner_aws_instructions.vue'; export default { components: { @@ -104,6 +105,8 @@ export default { return RunnerDockerInstructions; case PLATFORM_KUBERNETES: return RunnerKubernetesInstructions; + case PLATFORM_AWS: + return RunnerAwsInstructions; default: return null; } diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue index 28a16cd846a..092e8ba6c15 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue @@ -1,64 +1,55 @@ <script> import { GlIntersectionObserver } from '@gitlab/ui'; -import LineHighlighter from '~/blob/line_highlighter'; -import ChunkLine from './chunk_line.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { getPageParamValue, getPageSearchString } from '~/blob/utils'; /* * We only highlight the chunk that is currently visible to the user. * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly. * - * Content that is not visible to the user (i.e. not highlighted) do not need to look nice, - * so by making text transparent and rendering raw (non-highlighted) text, - * the browser spends less resources on painting content that is not immediately relevant. - * - * Why use transparent text as opposed to hiding content entirely? - * 1. If content is hidden entirely, native find text (⌘ + F) won't work. - * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line. + * Content that is not visible to the user (i.e. not highlighted) does not need to look nice, + * so by rendering raw (non-highlighted) text, the browser spends less resources on painting + * content that is not immediately relevant. + * Why use plaintext as opposed to hiding content entirely? + * If content is hidden entirely, native find text (⌘ + F) won't work. */ export default { components: { - ChunkLine, GlIntersectionObserver, }, + directives: { + SafeHtml, + }, + mixins: [glFeatureFlagMixin()], props: { - isFirstChunk: { + isHighlighted: { type: Boolean, - required: false, - default: false, + required: true, }, chunkIndex: { type: Number, required: false, default: 0, }, - isHighlighted: { - type: Boolean, + rawContent: { + type: String, required: true, }, - content: { + highlightedContent: { type: String, required: true, }, - startingFrom: { - type: Number, - required: false, - default: 0, - }, totalLines: { type: Number, required: false, default: 0, }, - totalChunks: { + startingFrom: { type: Number, required: false, default: 0, }, - language: { - type: String, - required: false, - default: null, - }, blamePath: { type: String, required: true, @@ -66,37 +57,37 @@ export default { }, data() { return { + hasAppeared: false, isLoading: true, }; }, computed: { + shouldHighlight() { + return Boolean(this.highlightedContent) && (this.hasAppeared || this.isHighlighted); + }, lines() { return this.content.split('\n'); }, + pageSearchString() { + if (!this.glFeatures.fileLineBlame) return ''; + const page = getPageParamValue(this.number); + return getPageSearchString(this.blamePath, page); + }, }, - created() { - if (this.isFirstChunk) { + if (this.chunkIndex === 0) { + // Display first chunk ASAP in order to improve perceived performance this.isLoading = false; return; } - window.requestIdleCallback(async () => { + window.requestIdleCallback(() => { this.isLoading = false; - const { hash } = this.$route; - if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) { - // when the last chunk is loaded scroll to the hash - await this.$nextTick(); - const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); - lineHighlighter.highlightHash(hash); - } }); }, methods: { handleChunkAppear() { - if (!this.isHighlighted) { - this.$emit('appear', this.chunkIndex); - } + this.hasAppeared = true; }, calculateLineNumber(index) { return this.startingFrom + index + 1; @@ -106,28 +97,37 @@ export default { </script> <template> <gl-intersection-observer @appear="handleChunkAppear"> - <div v-if="isHighlighted"> - <chunk-line - v-for="(line, index) in lines" - :key="index" - :number="calculateLineNumber(index)" - :content="line" - :language="language" - :blame-path="blamePath" - /> - </div> - <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent"> - <div class="gl-display-flex gl-flex-direction-column content-visibility-auto"> - <span + <div class="gl-display-flex"> + <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column"> + <div v-for="(n, index) in totalLines" - v-once - :id="`L${calculateLineNumber(index)}`" :key="index" - data-testid="line-number" - v-text="calculateLineNumber(index)" - ></span> + data-testid="line-numbers" + class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" + > + <a + v-if="glFeatures.fileLineBlame" + class="gl-user-select-none gl-shadow-none! file-line-blame" + :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`" + ></a> + <a + :id="`L${calculateLineNumber(index)}`" + class="gl-user-select-none gl-shadow-none! file-line-num" + :href="`#L${calculateLineNumber(index)}`" + :data-line-number="calculateLineNumber(index)" + > + {{ calculateLineNumber(index) }} + </a> + </div> </div> - <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div> + + <div v-else-if="!isLoading" class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent"> + <!-- Placeholder for line numbers while content is not highlighted --> + </div> + + <pre + class="gl-m-0 gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0" + ><code v-if="shouldHighlight" v-once v-safe-html="highlightedContent" data-testid="content"></code><code v-else-if="!isLoading" v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre> </div> </gl-intersection-observer> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue new file mode 100644 index 00000000000..28a16cd846a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue @@ -0,0 +1,133 @@ +<script> +import { GlIntersectionObserver } from '@gitlab/ui'; +import LineHighlighter from '~/blob/line_highlighter'; +import ChunkLine from './chunk_line.vue'; + +/* + * We only highlight the chunk that is currently visible to the user. + * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly. + * + * Content that is not visible to the user (i.e. not highlighted) do not need to look nice, + * so by making text transparent and rendering raw (non-highlighted) text, + * the browser spends less resources on painting content that is not immediately relevant. + * + * Why use transparent text as opposed to hiding content entirely? + * 1. If content is hidden entirely, native find text (⌘ + F) won't work. + * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line. + */ +export default { + components: { + ChunkLine, + GlIntersectionObserver, + }, + props: { + isFirstChunk: { + type: Boolean, + required: false, + default: false, + }, + chunkIndex: { + type: Number, + required: false, + default: 0, + }, + isHighlighted: { + type: Boolean, + required: true, + }, + content: { + type: String, + required: true, + }, + startingFrom: { + type: Number, + required: false, + default: 0, + }, + totalLines: { + type: Number, + required: false, + default: 0, + }, + totalChunks: { + type: Number, + required: false, + default: 0, + }, + language: { + type: String, + required: false, + default: null, + }, + blamePath: { + type: String, + required: true, + }, + }, + data() { + return { + isLoading: true, + }; + }, + computed: { + lines() { + return this.content.split('\n'); + }, + }, + + created() { + if (this.isFirstChunk) { + this.isLoading = false; + return; + } + + window.requestIdleCallback(async () => { + this.isLoading = false; + const { hash } = this.$route; + if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) { + // when the last chunk is loaded scroll to the hash + await this.$nextTick(); + const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + lineHighlighter.highlightHash(hash); + } + }); + }, + methods: { + handleChunkAppear() { + if (!this.isHighlighted) { + this.$emit('appear', this.chunkIndex); + } + }, + calculateLineNumber(index) { + return this.startingFrom + index + 1; + }, + }, +}; +</script> +<template> + <gl-intersection-observer @appear="handleChunkAppear"> + <div v-if="isHighlighted"> + <chunk-line + v-for="(line, index) in lines" + :key="index" + :number="calculateLineNumber(index)" + :content="line" + :language="language" + :blame-path="blamePath" + /> + </div> + <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent"> + <div class="gl-display-flex gl-flex-direction-column content-visibility-auto"> + <span + v-for="(n, index) in totalLines" + v-once + :id="`L${calculateLineNumber(index)}`" + :key="index" + data-testid="line-number" + v-text="calculateLineNumber(index)" + ></span> + </div> + <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div> + </div> + </gl-intersection-observer> +</template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js index f382ded90d7..15335ea6edc 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -120,6 +120,8 @@ export const EVENT_LABEL_FALLBACK = 'legacy_fallback'; export const LINES_PER_CHUNK = 70; +export const NEWLINE = '\n'; + export const BIDI_CHARS = [ '\u202A', // Left-to-Right Embedding (Try treating following text as left-to-right) '\u202B', // Right-to-Left Embedding (Try treating following text as right-to-left) 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 efafa67a733..11708b6f1f6 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 @@ -1,192 +1,40 @@ <script> -import { 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 addBlobLinksTracking from '~/blob/blob_links_tracking'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; -import { - EVENT_ACTION, - EVENT_LABEL_VIEWER, - EVENT_LABEL_FALLBACK, - ROUGE_TO_HLJS_LANGUAGE_MAP, - LINES_PER_CHUNK, - LEGACY_FALLBACKS, -} from './constants'; +import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants'; import Chunk from './components/chunk.vue'; -import { registerPlugins } from './plugins/index'; -/* - * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code, - * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible. - * - * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback). - * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes, - * it does not trigger a repaint on a parent element that wraps all 1000 lines. - */ export default { components: { - GlLoadingIcon, Chunk, }, + directives: { + SafeHtml, + }, mixins: [Tracking.mixin()], + inject: { + highlightWorker: { default: null }, + }, props: { blob: { type: Object, required: true, }, - }, - data() { - return { - languageDefinition: null, - content: this.blob.rawTextBlob, - language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()], - hljs: null, - firstChunk: null, - chunks: {}, - isLoading: true, - isLineSelected: false, - lineHighlighter: null, - }; - }, - computed: { - splitContent() { - return this.content.split(/\r?\n/); - }, - lineNumbers() { - return this.splitContent.length; - }, - unsupportedLanguage() { - const supportedLanguages = Object.keys(languageLoader); - const unsupportedLanguage = - !supportedLanguages.includes(this.language) && - !supportedLanguages.includes(this.blob.language?.toLowerCase()); - - return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage; - }, - totalChunks() { - return Object.keys(this.chunks).length; + chunks: { + type: Array, + required: false, + default: () => [], }, }, - async created() { + created() { + this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language }); addBlobLinksTracking(); - this.trackEvent(EVENT_LABEL_VIEWER); - - if (this.unsupportedLanguage) { - this.handleUnsupportedLanguage(); - return; - } - - this.generateFirstChunk(); - this.hljs = await this.loadHighlightJS(); - - if (this.language) { - this.languageDefinition = await this.loadLanguage(); - } - - // Highlight the first chunk as soon as highlight.js is available - this.highlightChunk(null, true); - - window.requestIdleCallback(async () => { - // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first - this.generateRemainingChunks(); - this.isLoading = false; - await this.$nextTick(); - this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); - }); - }, - methods: { - trackEvent(label) { - this.track(EVENT_ACTION, { label, property: this.blob.language }); - }, - handleUnsupportedLanguage() { - this.trackEvent(EVENT_LABEL_FALLBACK); - this.$emit('error'); - }, - generateFirstChunk() { - const lines = this.splitContent.splice(0, LINES_PER_CHUNK); - this.firstChunk = this.createChunk(lines); - }, - generateRemainingChunks() { - const result = {}; - for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) { - const chunkIndex = Math.floor(i / LINES_PER_CHUNK); - const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK); - result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK); - } - - this.chunks = result; - }, - createChunk(lines, startingFrom = 0) { - return { - content: lines.join('\n'), - startingFrom, - totalLines: lines.length, - language: this.language, - isHighlighted: false, - }; - }, - highlightChunk(index, isFirstChunk) { - const chunk = isFirstChunk ? this.firstChunk : this.chunks[index]; - - if (chunk.isHighlighted) { - return; - } - - const { highlightedContent, language } = this.highlight(chunk.content, this.language); - - Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true }); - - this.selectLine(); - - this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path)); - }, - highlight(content, language) { - let detectedLanguage = language; - let highlightedContent; - if (this.hljs) { - registerPlugins(this.hljs, this.blob.fileType, this.content); - if (!detectedLanguage) { - const hljsHighlightAuto = this.hljs.highlightAuto(content); - highlightedContent = hljsHighlightAuto.value; - detectedLanguage = hljsHighlightAuto.language; - } else if (this.languageDefinition) { - highlightedContent = this.hljs.highlight(content, { language: this.language }).value; - } - } - - return { highlightedContent, language: detectedLanguage }; - }, - loadHighlightJS() { - // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint) - return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); - }, - async loadLanguage() { - let languageDefinition; - - try { - languageDefinition = await languageLoader[this.language](); - this.hljs.registerLanguage(this.language, languageDefinition.default); - } catch (message) { - this.$emit('error', message); - } - - return languageDefinition; - }, - async selectLine() { - if (this.isLineSelected || !this.lineHighlighter) { - return; - } - - this.isLineSelected = true; - await this.$nextTick(); - this.lineHighlighter.highlightHash(this.$route.hash); - }, }, userColorScheme: window.gon.user_color_scheme, - currentlySelectedLine: null, }; </script> + <template> <div class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto" @@ -196,32 +44,15 @@ export default { data-qa-selector="blob_viewer_file_content" > <chunk - v-if="firstChunk" - :lines="firstChunk.lines" - :total-lines="firstChunk.totalLines" - :content="firstChunk.content" - :starting-from="firstChunk.startingFrom" - :is-highlighted="firstChunk.isHighlighted" - is-first-chunk - :language="firstChunk.language" - :blame-path="blob.blamePath" - /> - - <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" /> - <chunk - v-for="(chunk, key, index) in chunks" - v-else - :key="key" - :lines="chunk.lines" - :content="chunk.content" + v-for="(chunk, _, index) in chunks" + :key="index" + :chunk-index="index" + :is-highlighted="Boolean(chunk.isHighlighted)" + :raw-content="chunk.rawContent" + :highlighted-content="chunk.highlightedContent" :total-lines="chunk.totalLines" :starting-from="chunk.startingFrom" - :is-highlighted="chunk.isHighlighted" - :chunk-index="index" - :language="chunk.language" :blame-path="blob.blamePath" - :total-chunks="totalChunks" - @appear="highlightChunk" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue new file mode 100644 index 00000000000..26cf45c7570 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue @@ -0,0 +1,227 @@ +<script> +import { 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 addBlobLinksTracking from '~/blob/blob_links_tracking'; +import Tracking from '~/tracking'; +import { + EVENT_ACTION, + EVENT_LABEL_VIEWER, + EVENT_LABEL_FALLBACK, + ROUGE_TO_HLJS_LANGUAGE_MAP, + LINES_PER_CHUNK, + LEGACY_FALLBACKS, +} from './constants'; +import Chunk from './components/chunk_deprecated.vue'; +import { registerPlugins } from './plugins/index'; + +/* + * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code, + * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible. + * + * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback). + * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes, + * it does not trigger a repaint on a parent element that wraps all 1000 lines. + */ +export default { + components: { + GlLoadingIcon, + Chunk, + }, + mixins: [Tracking.mixin()], + props: { + blob: { + type: Object, + required: true, + }, + }, + data() { + return { + languageDefinition: null, + content: this.blob.rawTextBlob, + language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()], + hljs: null, + firstChunk: null, + chunks: {}, + isLoading: true, + isLineSelected: false, + lineHighlighter: null, + }; + }, + computed: { + splitContent() { + return this.content.split(/\r?\n/); + }, + lineNumbers() { + return this.splitContent.length; + }, + unsupportedLanguage() { + const supportedLanguages = Object.keys(languageLoader); + const unsupportedLanguage = + !supportedLanguages.includes(this.language) && + !supportedLanguages.includes(this.blob.language?.toLowerCase()); + + return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage; + }, + totalChunks() { + return Object.keys(this.chunks).length; + }, + }, + async created() { + addBlobLinksTracking(); + this.trackEvent(EVENT_LABEL_VIEWER); + + if (this.unsupportedLanguage) { + this.handleUnsupportedLanguage(); + return; + } + + this.generateFirstChunk(); + this.hljs = await this.loadHighlightJS(); + + if (this.language) { + this.languageDefinition = await this.loadLanguage(); + } + + // Highlight the first chunk as soon as highlight.js is available + this.highlightChunk(null, true); + + window.requestIdleCallback(async () => { + // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first + this.generateRemainingChunks(); + this.isLoading = false; + await this.$nextTick(); + this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + }); + }, + methods: { + trackEvent(label) { + this.track(EVENT_ACTION, { label, property: this.blob.language }); + }, + handleUnsupportedLanguage() { + this.trackEvent(EVENT_LABEL_FALLBACK); + this.$emit('error'); + }, + generateFirstChunk() { + const lines = this.splitContent.splice(0, LINES_PER_CHUNK); + this.firstChunk = this.createChunk(lines); + }, + generateRemainingChunks() { + const result = {}; + for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) { + const chunkIndex = Math.floor(i / LINES_PER_CHUNK); + const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK); + result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK); + } + + this.chunks = result; + }, + createChunk(lines, startingFrom = 0) { + return { + content: lines.join('\n'), + startingFrom, + totalLines: lines.length, + language: this.language, + isHighlighted: false, + }; + }, + highlightChunk(index, isFirstChunk) { + const chunk = isFirstChunk ? this.firstChunk : this.chunks[index]; + + if (chunk.isHighlighted) { + return; + } + + const { highlightedContent, language } = this.highlight(chunk.content, this.language); + + Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true }); + + this.selectLine(); + + this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path)); + }, + highlight(content, language) { + let detectedLanguage = language; + let highlightedContent; + if (this.hljs) { + registerPlugins(this.hljs, this.blob.fileType, this.content); + if (!detectedLanguage) { + const hljsHighlightAuto = this.hljs.highlightAuto(content); + highlightedContent = hljsHighlightAuto.value; + detectedLanguage = hljsHighlightAuto.language; + } else if (this.languageDefinition) { + highlightedContent = this.hljs.highlight(content, { language: this.language }).value; + } + } + + return { highlightedContent, language: detectedLanguage }; + }, + loadHighlightJS() { + // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint) + return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); + }, + async loadLanguage() { + let languageDefinition; + + try { + languageDefinition = await languageLoader[this.language](); + this.hljs.registerLanguage(this.language, languageDefinition.default); + } catch (message) { + this.$emit('error', message); + } + + return languageDefinition; + }, + async selectLine() { + if (this.isLineSelected || !this.lineHighlighter) { + return; + } + + this.isLineSelected = true; + await this.$nextTick(); + this.lineHighlighter.highlightHash(this.$route.hash); + }, + }, + userColorScheme: window.gon.user_color_scheme, + currentlySelectedLine: null, +}; +</script> +<template> + <div + class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto" + :class="$options.userColorScheme" + data-type="simple" + :data-path="blob.path" + data-qa-selector="blob_viewer_file_content" + > + <chunk + v-if="firstChunk" + :lines="firstChunk.lines" + :total-lines="firstChunk.totalLines" + :content="firstChunk.content" + :starting-from="firstChunk.startingFrom" + :is-highlighted="firstChunk.isHighlighted" + is-first-chunk + :language="firstChunk.language" + :blame-path="blob.blamePath" + /> + + <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" /> + <chunk + v-for="(chunk, key, index) in chunks" + v-else + :key="key" + :lines="chunk.lines" + :content="chunk.content" + :total-lines="chunk.totalLines" + :starting-from="chunk.startingFrom" + :is-highlighted="chunk.isHighlighted" + :chunk-index="index" + :language="chunk.language" + :blame-path="blob.blamePath" + :total-chunks="totalChunks" + @appear="highlightChunk" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js index 0da57f9e6fa..142c135e9c1 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js @@ -1,15 +1,47 @@ -import hljs from 'highlight.js/lib/core'; -import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import hljs from 'highlight.js'; import { registerPlugins } from '../plugins/index'; +import { LINES_PER_CHUNK, NEWLINE, ROUGE_TO_HLJS_LANGUAGE_MAP } from '../constants'; -const initHighlightJs = async (fileType, content, language) => { - const languageDefinition = await languageLoader[language](); - +const initHighlightJs = (fileType, content) => { registerPlugins(hljs, fileType, content); - hljs.registerLanguage(language, languageDefinition.default); }; -export const highlight = (fileType, content, language) => { - initHighlightJs(fileType, content, language); - return hljs.highlight(content, { language }).value; +const splitByLineBreaks = (content = '') => content.split(/\r?\n/); + +const createChunk = (language, rawChunkLines, highlightedChunkLines = [], startingFrom = 0) => ({ + highlightedContent: highlightedChunkLines.join(NEWLINE), + rawContent: rawChunkLines.join(NEWLINE), + totalLines: rawChunkLines.length, + startingFrom, + language, +}); + +const splitIntoChunks = (language, rawContent, highlightedContent) => { + const result = []; + const splitRawContent = splitByLineBreaks(rawContent); + const splitHighlightedContent = splitByLineBreaks(highlightedContent); + + for (let i = 0; i < splitRawContent.length; i += LINES_PER_CHUNK) { + const chunkIndex = Math.floor(i / LINES_PER_CHUNK); + const highlightedChunk = splitHighlightedContent.slice(i, i + LINES_PER_CHUNK); + const rawChunk = splitRawContent.slice(i, i + LINES_PER_CHUNK); + result[chunkIndex] = createChunk(language, rawChunk, highlightedChunk, i); + } + + return result; +}; + +const highlight = (fileType, rawContent, lang) => { + const language = ROUGE_TO_HLJS_LANGUAGE_MAP[lang.toLowerCase()]; + let result; + + if (language) { + initHighlightJs(fileType, rawContent, language); + const highlightedContent = hljs.highlight(rawContent, { language }).value; + result = splitIntoChunks(language, rawContent, highlightedContent); + } + + return result; }; + +export { highlight, splitIntoChunks }; diff --git a/app/assets/javascripts/vue_shared/components/url_sync.vue b/app/assets/javascripts/vue_shared/components/url_sync.vue index bd5b7b77017..ad81c14d9e5 100644 --- a/app/assets/javascripts/vue_shared/components/url_sync.vue +++ b/app/assets/javascripts/vue_shared/components/url_sync.vue @@ -1,7 +1,9 @@ <script> -import { historyPushState } from '~/lib/utils/common_utils'; +import { historyPushState, historyReplaceState } from '~/lib/utils/common_utils'; import { mergeUrlParams, setUrlParams } from '~/lib/utils/url_utility'; +export const HISTORY_PUSH_UPDATE_METHOD = 'push'; +export const HISTORY_REPLACE_UPDATE_METHOD = 'replace'; export const URL_SET_PARAMS_STRATEGY = 'set'; export const URL_MERGE_PARAMS_STRATEGY = 'merge'; @@ -24,6 +26,13 @@ export default { default: URL_MERGE_PARAMS_STRATEGY, validator: (value) => [URL_MERGE_PARAMS_STRATEGY, URL_SET_PARAMS_STRATEGY].includes(value), }, + historyUpdateMethod: { + type: String, + required: false, + default: HISTORY_PUSH_UPDATE_METHOD, + validator: (value) => + [HISTORY_PUSH_UPDATE_METHOD, HISTORY_REPLACE_UPDATE_METHOD].includes(value), + }, }, watch: { query: { @@ -40,9 +49,14 @@ export default { updateQuery(newQuery) { const url = this.urlParamsUpdateStrategy === URL_SET_PARAMS_STRATEGY - ? setUrlParams(this.query, window.location.href, true) + ? setUrlParams(this.query, window.location.href, true, true, true) : mergeUrlParams(newQuery, window.location.href, { spreadArrays: true }); - historyPushState(url); + + if (this.historyUpdateMethod === HISTORY_PUSH_UPDATE_METHOD) { + historyPushState(url); + } else { + historyReplaceState(url); + } }, }, render() { diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue index 231f5ff3d1f..167db3ce1f2 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue @@ -74,8 +74,8 @@ export default { <user-avatar-link v-for="item in visibleItems" :key="item.id" - :link-href="item.web_url" - :img-src="item.avatar_url" + :link-href="item.web_url || item.webUrl" + :img-src="item.avatar_url || item.avatarUrl" :img-alt="item.name" :tooltip-text="item.name" :img-size="imgSize" 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 86a99b8f0ed..edcfabe7da3 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 @@ -2,18 +2,19 @@ import { debounce } from 'lodash'; import { GlDropdown, - GlDropdownForm, GlDropdownDivider, + GlDropdownForm, GlDropdownItem, - GlSearchBoxByType, GlLoadingIcon, + GlSearchBoxByType, GlTooltipDirective, } from '@gitlab/ui'; import { __ } from '~/locale'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { participantsQueries, userSearchQueries } from '~/sidebar/constants'; +import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; export default { @@ -47,7 +48,8 @@ export default { }, iid: { type: String, - required: true, + required: false, + default: null, }, value: { type: Array, @@ -65,7 +67,7 @@ export default { issuableType: { type: String, required: false, - default: IssuableType.Issue, + default: TYPE_ISSUE, }, isEditing: { type: Boolean, @@ -160,20 +162,17 @@ export default { } return { ...variables, - mergeRequestId: convertToGraphQLId('MergeRequest', this.issuableId), + mergeRequestId: convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.issuableId), }; }, isLoading() { return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading; }, users() { - if (!this.participants) { - return []; - } - - const filteredParticipants = this.participants.filter( - (user) => user.name.includes(this.search) || user.username.includes(this.search), - ); + const filteredParticipants = + this.participants?.filter( + (user) => user.name.includes(this.search) || user.username.includes(this.search), + ) || []; // TODO this de-duplication is temporary (BE fix required) // https://gitlab.com/gitlab-org/gitlab/-/issues/327822 @@ -254,6 +253,10 @@ export default { this.$emit('input', selected); } }, + unassign() { + this.$emit('input', []); + this.$refs.dropdown.hide(); + }, unselect(name) { const selected = this.value.filter((user) => user.username !== name); this.$emit('input', selected); @@ -323,7 +326,7 @@ export default { :is-checked="selectedIsEmpty" is-check-centered data-testid="unassign" - @click.native.capture.stop="$emit('input', [])" + @click.native.capture.stop="unassign" > <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{ $options.i18n.unassigned diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 98630512308..28bec63b244 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -423,6 +423,7 @@ export default { target="_blank" :href="webIdeUrl" block + @click="dismissCalloutOnActionClicked(dismiss)" > {{ __('Try it out now') }} </gl-link> diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index c93dd95a886..fd151751372 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -1,5 +1,5 @@ import { __, n__, sprintf } from '~/locale'; -import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { TYPE_ISSUE, WorkspaceType } from '~/issues/constants'; const INTERVALS = { minute: 'minute', @@ -88,9 +88,9 @@ export const confidentialityInfoText = (workspaceType, issuableType) => ), { workspaceType: workspaceType === WorkspaceType.project ? __('project') : __('group'), - issuableType: issuableType === IssuableType.Issue ? __('issue') : __('epic'), + issuableType: issuableType === TYPE_ISSUE ? __('issue') : __('epic'), permissions: - issuableType === IssuableType.Issue + issuableType === TYPE_ISSUE ? __('at least the Reporter role, the author, and assignees') : __('at least the Reporter role'), }, diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue index 5eb3da3c62e..d78530239a5 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue @@ -173,6 +173,7 @@ export default { :can-edit="enableEdit" :task-list-update-path="taskListUpdatePath" /> + <slot name="secondary-content"></slot> <small v-if="isUpdated" class="edited-text gl-font-sm!"> {{ __('Edited') }} <time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" /> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/constants.js b/app/assets/javascripts/vue_shared/security_reports/components/constants.js index 9b1cbfe218b..6fe98764fcd 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/components/constants.js @@ -1,8 +1,8 @@ export const SEVERITY_CLASS_NAME_MAP = { - critical: 'text-danger-800', - high: 'text-danger-600', - medium: 'text-warning-400', - low: 'text-warning-200', - info: 'text-primary-400', - unknown: 'text-secondary-400', + critical: 'gl-text-red-800', + high: 'gl-text-red-600', + medium: 'gl-text-orange-400', + low: 'gl-text-orange-200', + info: 'gl-text-blue-400', + unknown: 'gl-text-gray-400', }; 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 f3cb5fc16f0..f620bad8dba 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js @@ -24,42 +24,37 @@ export const fetchDiffData = (state, endpoint, category) => { /** * Returns given vulnerability enriched with the corresponding * feedback (`dismissal` or `issue` type) - * @param {Object} vulnerability - * @param {Array} feedback + * @param {Object} vulnerabilityObject + * @param {Array} feedbackList */ -export const enrichVulnerabilityWithFeedback = (vulnerability, feedback = []) => - feedback +export const enrichVulnerabilityWithFeedback = (vulnerabilityObject, feedbackList = []) => { + const vulnerability = { ...vulnerabilityObject }; + // Some records may have a null `uuid`, we need to fallback to using `project_fingerprint` in those cases. Once all entries have been fixed, we can remove the fallback. + // related epic: https://gitlab.com/groups/gitlab-org/-/epics/2791 + feedbackList .filter((fb) => - // Some records still have a `finding_uuid` with null, we need to fallback to using `project_fingerprint` in those cases. Once all entries have been fixed, we can remove the fallback. - // related epic: https://gitlab.com/groups/gitlab-org/-/epics/2791 - fb.finding_uuid !== null - ? fb.finding_uuid === vulnerability.finding_uuid + fb.finding_uuid + ? fb.finding_uuid === vulnerability.uuid : fb.project_fingerprint === vulnerability.project_fingerprint, ) - .reduce((vuln, fb) => { - if (fb.feedback_type === FEEDBACK_TYPE_DISMISSAL) { - return { - ...vuln, - isDismissed: true, - dismissalFeedback: fb, - }; + .forEach((feedback) => { + if (feedback.feedback_type === FEEDBACK_TYPE_DISMISSAL) { + vulnerability.isDismissed = true; + vulnerability.dismissalFeedback = feedback; + } else if (feedback.feedback_type === FEEDBACK_TYPE_ISSUE && feedback.issue_iid) { + vulnerability.hasIssue = true; + vulnerability.issue_feedback = feedback; + } else if ( + feedback.feedback_type === FEEDBACK_TYPE_MERGE_REQUEST && + feedback.merge_request_iid + ) { + vulnerability.hasMergeRequest = true; + vulnerability.merge_request_feedback = feedback; } - if (fb.feedback_type === FEEDBACK_TYPE_ISSUE && fb.issue_iid) { - return { - ...vuln, - hasIssue: true, - issue_feedback: fb, - }; - } - if (fb.feedback_type === FEEDBACK_TYPE_MERGE_REQUEST && fb.merge_request_iid) { - return { - ...vuln, - hasMergeRequest: true, - merge_request_feedback: fb, - }; - } - return vuln; - }, vulnerability); + }); + + return vulnerability; +}; /** * Generates the added, fixed, and existing vulnerabilities from the API report. diff --git a/app/assets/javascripts/webhooks/components/test_dropdown.vue b/app/assets/javascripts/webhooks/components/test_dropdown.vue new file mode 100644 index 00000000000..78e5dff6f59 --- /dev/null +++ b/app/assets/javascripts/webhooks/components/test_dropdown.vue @@ -0,0 +1,69 @@ +<script> +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'HookTestDropdown', + components: { + GlDisclosureDropdown, + }, + props: { + items: { + type: Array, + required: true, + }, + size: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + itemsWithAction() { + return this.items.map((item) => ({ + text: item.text, + action: () => this.testHook(item.href), + })); + }, + }, + methods: { + testHook(href) { + // HACK: Trigger @rails/ujs's data-method handling. + // + // The more obvious approaches of (1) declaratively rendering the + // links using GlDisclosureDropdown's list-item slot and (2) using + // item.extraAttrs to set the data-method attributes on the links + // do not work for reasons laid out in + // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2134. + // + // Sending the POST with axios also doesn't work, since the + // endpoints return 302 redirects. Since axios uses XMLHTTPRequest, + // it transparently follows redirects, meaning the Location header + // of the first response cannot be inspected/acted upon by JS. We + // could manually trigger a reload afterwards, but that would mean + // a duplicate fetch of the current page: one by the XHR, and one + // by the explicit reload. It would also mean losing the flash + // alert set by the backend, making the feature useless for the + // user. + // + // The ideal fix here would be to refactor the test endpoint to + // return a JSON response, removing the need for a redirect/page + // reload to show the result. + const a = document.createElement('a'); + a.setAttribute('hidden', ''); + a.href = href; + a.dataset.method = 'post'; + document.body.appendChild(a); + a.click(); + a.remove(); + }, + }, + i18n: { + test: __('Test'), + }, +}; +</script> + +<template> + <gl-disclosure-dropdown :toggle-text="$options.i18n.test" :items="itemsWithAction" :size="size" /> +</template> diff --git a/app/assets/javascripts/webhooks/index.js b/app/assets/javascripts/webhooks/index.js index 7d04978280b..6eb7cbea72c 100644 --- a/app/assets/javascripts/webhooks/index.js +++ b/app/assets/javascripts/webhooks/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import FormUrlApp from './components/form_url_app.vue'; +import TestDropdown from './components/test_dropdown.vue'; export default () => { const el = document.querySelector('.js-vue-webhook-form'); @@ -23,3 +24,22 @@ export default () => { }, }); }; + +const initHookTestDropdown = (el) => { + const { items, size } = el.dataset; + + return new Vue({ + el, + render(h) { + return h(TestDropdown, { + props: { + items: JSON.parse(items), + size, + }, + }); + }, + }); +}; + +export const initHookTestDropdowns = (selector = '.js-webhook-test-dropdown') => + document.querySelectorAll(selector).forEach(initHookTestDropdown); diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue index 9e5361e8302..472bc1dfacc 100644 --- a/app/assets/javascripts/whats_new/components/app.vue +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -35,7 +35,11 @@ export default { const body = document.querySelector('body'); const { namespaceId } = body.dataset; - this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId }); + this.track('click_whats_new_drawer', { + label: 'namespace_id', + value: namespaceId, + property: 'navigation_top', + }); }, methods: { ...mapActions(['openDrawer', 'closeDrawer', 'fetchItems', 'setDrawerBodyHeight']), diff --git a/app/assets/javascripts/whats_new/utils/notification.js b/app/assets/javascripts/whats_new/utils/notification.js index 41aff202f48..f9b725ed429 100644 --- a/app/assets/javascripts/whats_new/utils/notification.js +++ b/app/assets/javascripts/whats_new/utils/notification.js @@ -5,6 +5,8 @@ export const getVersionDigest = (appEl) => appEl.dataset.versionDigest; export const setNotification = (appEl) => { const versionDigest = getVersionDigest(appEl); const notificationEl = document.querySelector('.header-help'); + if (!notificationEl) return; + let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count'); if (localStorage.getItem(STORAGE_KEY) === versionDigest) { diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue index 2a0913e380a..8ec8482657d 100644 --- a/app/assets/javascripts/work_items/components/item_state.vue +++ b/app/assets/javascripts/work_items/components/item_state.vue @@ -62,6 +62,7 @@ export default { :value="state" :options="$options.states" :disabled="disabled" + data-testid="work-item-state-select" class="gl-w-auto hide-select-decoration gl-pl-3" :class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }" @change="setState" diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index b2c8b7ae1db..6aa3c54705c 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -35,7 +35,7 @@ export default { <template> <h2 - class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-5 gl-mt-0 gl-w-full" + class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-1 gl-mt-0 gl-w-full" :class="{ 'gl-cursor-text': disabled }" aria-labelledby="item-title" > diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue index 92a2fcaf1df..bca061f5e01 100644 --- a/app/assets/javascripts/work_items/components/notes/system_note.vue +++ b/app/assets/javascripts/work_items/components/notes/system_note.vue @@ -22,6 +22,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; import axios from '~/lib/utils/axios_utils'; import { getLocationHash } from '~/lib/utils/url_utility'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import NoteHeader from '~/notes/components/note_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -87,6 +88,9 @@ export default { descriptionVersion() { return this.descriptionVersions[this.note.description_version_id]; }, + noteId() { + return getIdFromGraphQLId(this.note.id); + }, }, mounted() { renderGFM(this.$refs['gfm-content']); @@ -129,7 +133,7 @@ export default { <note-header :author="note.author" :created-at="note.createdAt" - :note-id="note.id" + :note-id="noteId" :is-system-note="true" > <span ref="gfm-content" v-safe-html="actionTextHtml"></span> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue new file mode 100644 index 00000000000..b3f17aff2ae --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue @@ -0,0 +1,211 @@ +<script> +import { GlAvatar, GlButton } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { clearDraft } from '~/lib/utils/autosave'; +import Tracking from '~/tracking'; +import { ASC } from '~/notes/constants'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { updateCommentState } from '~/work_items/graphql/cache_utils'; +import { getWorkItemQuery } from '../../utils'; +import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql'; +import { TRACKING_CATEGORY_SHOW, i18n } from '../../constants'; +import WorkItemNoteSignedOut from './work_item_note_signed_out.vue'; +import WorkItemCommentLocked from './work_item_comment_locked.vue'; +import WorkItemCommentForm from './work_item_comment_form.vue'; + +export default { + constantOptions: { + avatarUrl: window.gon.current_user_avatar_url, + }, + components: { + GlAvatar, + GlButton, + WorkItemNoteSignedOut, + WorkItemCommentLocked, + WorkItemCommentForm, + }, + mixins: [glFeatureFlagMixin(), Tracking.mixin()], + props: { + workItemId: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, + queryVariables: { + type: Object, + required: true, + }, + discussionId: { + type: String, + required: false, + default: '', + }, + autofocus: { + type: Boolean, + required: false, + default: false, + }, + addPadding: { + type: Boolean, + required: false, + default: false, + }, + workItemType: { + type: String, + required: true, + }, + sortOrder: { + type: String, + required: false, + default: ASC, + }, + }, + data() { + return { + workItem: {}, + isEditing: false, + isSubmitting: false, + isSubmittingWithKeydown: false, + }; + }, + apollo: { + workItem: { + query() { + return getWorkItemQuery(this.fetchByIid); + }, + variables() { + return this.queryVariables; + }, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + }, + skip() { + return !this.queryVariables.id && !this.queryVariables.iid; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + }, + computed: { + signedIn() { + return Boolean(window.gon.current_user_id); + }, + autosaveKey() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return this.discussionId ? `${this.discussionId}-comment` : `${this.workItemId}-comment`; + }, + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_comment', + property: `type_${this.workItemType}`, + }; + }, + markdownPreviewPath() { + return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${ + this.workItemType + }`; + }, + timelineEntryClass() { + return { + 'timeline-entry gl-mb-3': true, + 'gl-p-4': this.addPadding, + }; + }, + isProjectArchived() { + return this.workItem?.project?.archived; + }, + canUpdate() { + return this.workItem?.userPermissions?.updateWorkItem; + }, + }, + watch: { + autofocus: { + immediate: true, + handler(focus) { + if (focus) { + this.isEditing = true; + } + }, + }, + }, + methods: { + async updateWorkItem(commentText) { + this.isSubmitting = true; + this.$emit('replying', commentText); + const { queryVariables, fetchByIid } = this; + + try { + this.track('add_work_item_comment'); + + await this.$apollo.mutate({ + mutation: createNoteMutation, + variables: { + input: { + noteableId: this.workItemId, + body: commentText, + discussionId: this.discussionId || null, + }, + }, + update(store, createNoteData) { + if (createNoteData.data?.createNote?.errors?.length) { + throw new Error(createNoteData.data?.createNote?.errors[0]); + } + updateCommentState(store, createNoteData, fetchByIid, queryVariables); + }, + }); + clearDraft(this.autosaveKey); + this.$emit('replied'); + this.cancelEditing(); + } catch (error) { + this.$emit('error', error.message); + Sentry.captureException(error); + } + + this.isSubmitting = false; + }, + cancelEditing() { + this.isEditing = false; + this.$emit('cancelEditing'); + }, + }, +}; +</script> + +<template> + <li :class="timelineEntryClass"> + <work-item-note-signed-out v-if="!signedIn" /> + <work-item-comment-locked + v-else-if="!canUpdate" + :work-item-type="workItemType" + :is-project-archived="isProjectArchived" + /> + <div v-else class="gl-relative gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap"> + <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" /> + <work-item-comment-form + v-if="isEditing" + :work-item-type="workItemType" + :aria-label="__('Add a comment')" + :is-submitting="isSubmitting" + :autosave-key="autosaveKey" + @submitForm="updateWorkItem" + @cancelEditing="cancelEditing" + /> + <gl-button + v-else + class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!" + @click="isEditing = true" + >{{ __('Add a comment') }}</gl-button + > + </div> + </li> +</template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue new file mode 100644 index 00000000000..fd407fd9d9f --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue @@ -0,0 +1,126 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__, __ } from '~/locale'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; + +export default { + constantOptions: { + markdownDocsPath: helpPagePath('user/markdown'), + }, + components: { + GlButton, + MarkdownEditor, + }, + inject: ['fullPath'], + props: { + workItemType: { + type: String, + required: true, + }, + ariaLabel: { + type: String, + required: true, + }, + autosaveKey: { + type: String, + required: true, + }, + isSubmitting: { + type: Boolean, + required: false, + default: false, + }, + initialValue: { + type: String, + required: false, + default: '', + }, + commentButtonText: { + type: String, + required: false, + default: __('Comment'), + }, + }, + data() { + return { + commentText: getDraft(this.autosaveKey) || this.initialValue || '', + }; + }, + computed: { + markdownPreviewPath() { + return joinPaths( + '/', + gon.relative_url_root || '', + this.fullPath, + `/preview_markdown?target_type=${this.workItemType}`, + ); + }, + formFieldProps() { + return { + 'aria-label': this.ariaLabel, + placeholder: __('Write a comment or drag your files here…'), + id: 'work-item-add-or-edit-comment', + name: 'work-item-add-or-edit-comment', + }; + }, + }, + methods: { + setCommentText(newText) { + this.commentText = newText; + updateDraft(this.autosaveKey, this.commentText); + }, + async cancelEditing() { + if (this.commentText && this.commentText !== this.initialValue) { + const msg = s__('WorkItem|Are you sure you want to cancel editing?'); + + const confirmed = await confirmAction(msg, { + primaryBtnText: __('Discard changes'), + cancelBtnText: __('Continue editing'), + primaryBtnVariant: 'danger', + }); + + if (!confirmed) { + return; + } + } + + this.$emit('cancelEditing'); + clearDraft(this.autosaveKey); + }, + }, +}; +</script> + +<template> + <form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1"> + <markdown-editor + :value="commentText" + :render-markdown-path="markdownPreviewPath" + :markdown-docs-path="$options.constantOptions.markdownDocsPath" + :form-field-props="formFieldProps" + data-testid="work-item-add-comment" + class="gl-mb-3" + autofocus + use-bottom-toolbar + @input="setCommentText" + @keydown.meta.enter="$emit('submitForm', commentText)" + @keydown.ctrl.enter="$emit('submitForm', commentText)" + @keydown.esc.stop="cancelEditing" + /> + <gl-button + category="primary" + variant="confirm" + data-testid="confirm-button" + :loading="isSubmitting" + @click="$emit('submitForm', commentText)" + >{{ commentButtonText }} + </gl-button> + <gl-button data-testid="cancel-button" category="primary" class="gl-ml-3" @click="cancelEditing" + >{{ __('Cancel') }} + </gl-button> + </form> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_comment_locked.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue index f837d025b7f..f837d025b7f 100644 --- a/app/assets/javascripts/work_items/components/work_item_comment_locked.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue new file mode 100644 index 00000000000..bda00f978b9 --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue @@ -0,0 +1,191 @@ +<script> +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { ASC } from '~/notes/constants'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import DiscussionNotesRepliesWrapper from '~/notes/components/discussion_notes_replies_wrapper.vue'; +import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue'; +import WorkItemNote from '~/work_items/components/notes/work_item_note.vue'; +import WorkItemNoteReplying from '~/work_items/components/notes/work_item_note_replying.vue'; +import WorkItemAddNote from './work_item_add_note.vue'; + +export default { + components: { + TimelineEntryItem, + GlAvatarLink, + GlAvatar, + WorkItemNote, + WorkItemAddNote, + ToggleRepliesWidget, + DiscussionNotesRepliesWrapper, + WorkItemNoteReplying, + }, + props: { + workItemId: { + type: String, + required: true, + }, + queryVariables: { + type: Object, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + workItemType: { + type: String, + required: true, + }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, + discussion: { + type: Array, + required: true, + }, + sortOrder: { + type: String, + default: ASC, + required: false, + }, + }, + data() { + return { + isExpanded: false, + autofocus: false, + isReplying: false, + replyingText: '', + }; + }, + computed: { + note() { + return this.discussion[0]; + }, + author() { + return this.note.author; + }, + noteAnchorId() { + return `note_${this.note.id}`; + }, + hasReplies() { + return this.replies?.length; + }, + replies() { + if (this.discussion?.length > 1) { + return this.discussion.slice(1); + } + return null; + }, + discussionId() { + return this.discussion[0]?.discussion?.id || ''; + }, + }, + methods: { + showReplyForm() { + this.isExpanded = true; + this.autofocus = true; + }, + hideReplyForm() { + this.isExpanded = this.hasReplies; + this.autofocus = false; + }, + toggleDiscussion() { + this.isExpanded = !this.isExpanded; + this.autofocus = this.isExpanded; + }, + threadKey(note) { + /* eslint-disable @gitlab/require-i18n-strings */ + return `${note.id}-thread`; + }, + onReplied() { + this.isExpanded = true; + this.isReplying = false; + this.replyingText = ''; + }, + onReplying(commentText) { + this.isReplying = true; + this.replyingText = commentText; + }, + }, +}; +</script> + +<template> + <timeline-entry-item + :id="noteAnchorId" + :class="{ 'internal-note': note.internal }" + :data-note-id="note.id" + class="note note-wrapper note-comment gl-px-0" + > + <div class="timeline-avatar gl-float-left"> + <gl-avatar-link :href="author.webUrl"> + <gl-avatar + :src="author.avatarUrl" + :entity-name="author.username" + :alt="author.name" + :size="32" + /> + </gl-avatar-link> + </div> + + <div class="timeline-content"> + <div class="discussion-body"> + <div class="discussion-wrapper"> + <div class="discussion-notes"> + <ul class="notes"> + <work-item-note + :is-first-note="true" + :note="note" + :discussion-id="discussionId" + :work-item-type="workItemType" + :class="{ 'gl-mb-5': hasReplies }" + @startReplying="showReplyForm" + @deleteNote="$emit('deleteNote', note)" + @error="$emit('error', $event)" + /> + <discussion-notes-replies-wrapper> + <toggle-replies-widget + v-if="hasReplies" + :collapsed="!isExpanded" + :replies="replies" + @toggle="toggleDiscussion({ discussionId })" + /> + <template v-if="isExpanded"> + <template v-for="reply in replies"> + <work-item-note + :key="threadKey(reply)" + :discussion-id="discussionId" + :note="reply" + :work-item-type="workItemType" + @startReplying="showReplyForm" + @deleteNote="$emit('deleteNote', reply)" + @error="$emit('error', $event)" + /> + </template> + <work-item-note-replying v-if="isReplying" :body="replyingText" /> + <work-item-add-note + :autofocus="autofocus" + :query-variables="queryVariables" + :full-path="fullPath" + :work-item-id="workItemId" + :fetch-by-iid="fetchByIid" + :discussion-id="discussionId" + :work-item-type="workItemType" + :sort-order="sortOrder" + :add-padding="true" + @cancelEditing="hideReplyForm" + @replied="onReplied" + @replying="onReplying" + @error="$emit('error', $event)" + /> + </template> + </discussion-notes-replies-wrapper> + </ul> + </div> + </div> + </div> + </div> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue index 5efa9c94f2b..5dd21a5f76f 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue @@ -1,42 +1,126 @@ <script> -import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { GlAvatarLink, GlAvatar, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { __ } from '~/locale'; +import { updateDraft, clearDraft } from '~/lib/utils/autosave'; +import { renderMarkdown } from '~/notes/utils'; +import EditedAt from '~/issues/show/components/edited.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import NoteBody from '~/work_items/components/notes/work_item_note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; +import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue'; +import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql'; +import WorkItemCommentForm from './work_item_comment_form.vue'; export default { + name: 'WorkItemNoteThread', + i18n: { + moreActionsText: __('More actions'), + deleteNoteText: __('Delete comment'), + }, components: { - NoteHeader, - NoteBody, TimelineEntryItem, - GlAvatarLink, + NoteBody, + NoteHeader, + NoteActions, GlAvatar, + GlAvatarLink, + GlDropdown, + GlDropdownItem, + WorkItemCommentForm, + EditedAt, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { note: { type: Object, required: true, }, + isFirstNote: { + type: Boolean, + required: false, + default: false, + }, + workItemType: { + type: String, + required: true, + }, + }, + data() { + return { + isEditing: false, + }; }, computed: { author() { return this.note.author; }, - noteAnchorId() { - return `note_${this.note.id}`; + entryClass() { + return { + 'note note-wrapper note-comment': true, + 'gl-p-4': !this.isFirstNote, + }; + }, + showReply() { + return this.note.userPermissions.createNote && this.isFirstNote; + }, + autosaveKey() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${this.note.id}-comment`; + }, + lastEditedBy() { + return this.note.lastEditedBy; + }, + hasAdminPermission() { + return this.note.userPermissions.adminNote; + }, + }, + methods: { + showReplyForm() { + this.$emit('startReplying'); + }, + startEditing() { + this.isEditing = true; + updateDraft(this.autosaveKey, this.note.body); + }, + async updateNote(newText) { + this.isEditing = false; + try { + await this.$apollo.mutate({ + mutation: updateWorkItemNoteMutation, + variables: { + input: { + id: this.note.id, + body: newText, + }, + }, + optimisticResponse: { + updateNote: { + errors: [], + note: { + ...this.note, + bodyHtml: renderMarkdown(newText), + }, + }, + }, + }); + clearDraft(this.autosaveKey); + } catch (error) { + updateDraft(this.autosaveKey, newText); + this.isEditing = true; + this.$emit('error', __('Something went wrong when updating a comment. Please try again')); + Sentry.captureException(error); + } }, }, }; </script> <template> - <timeline-entry-item - :id="noteAnchorId" - :class="{ 'internal-note': note.internal }" - :data-note-id="note.id" - class="note note-wrapper note-comment" - > - <div class="timeline-avatar gl-float-left"> + <timeline-entry-item :class="entryClass"> + <div v-if="!isFirstNote" :key="note.id" class="timeline-avatar gl-float-left"> <gl-avatar-link :href="author.webUrl"> <gl-avatar :src="author.avatarUrl" @@ -46,14 +130,57 @@ export default { /> </gl-avatar-link> </div> - - <div class="timeline-content"> + <work-item-comment-form + v-if="isEditing" + :work-item-type="workItemType" + :aria-label="__('Edit comment')" + :autosave-key="autosaveKey" + :initial-value="note.body" + :comment-button-text="__('Save comment')" + :class="{ 'gl-pl-8': !isFirstNote }" + @cancelEditing="isEditing = false" + @submitForm="updateNote" + /> + <div v-else class="timeline-content-inner" data-testid="note-wrapper"> <div class="note-header"> <note-header :author="author" :created-at="note.createdAt" :note-id="note.id" /> + <note-actions + :show-reply="showReply" + :show-edit="hasAdminPermission" + @startReplying="showReplyForm" + @startEditing="startEditing" + /> + <!-- v-if condition should be moved to "delete" dropdown item as soon as we implement copying the link --> + <gl-dropdown + v-if="hasAdminPermission" + v-gl-tooltip + icon="ellipsis_v" + text-sr-only + right + :text="$options.i18n.moreActionsText" + :title="$options.i18n.moreActionsText" + category="tertiary" + no-caret + > + <gl-dropdown-item + variant="danger" + data-testid="delete-note-action" + @click="$emit('deleteNote')" + > + {{ $options.i18n.deleteNoteText }} + </gl-dropdown-item> + </gl-dropdown> </div> <div class="timeline-discussion-body"> - <note-body :note="note" /> + <note-body ref="noteBody" :note="note" /> </div> + <edited-at + v-if="note.lastEditedBy" + :updated-at="note.lastEditedAt" + :updated-by-name="lastEditedBy.name" + :updated-by-path="lastEditedBy.webPath" + :class="isFirstNote ? 'gl-pl-3' : 'gl-pl-8'" + /> </div> </timeline-entry-item> </template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue new file mode 100644 index 00000000000..c17e855e527 --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue @@ -0,0 +1,47 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; + +export default { + name: 'WorkItemNoteActions', + i18n: { + editButtonText: __('Edit comment'), + }, + components: { + GlButton, + ReplyButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + showReply: { + type: Boolean, + required: true, + }, + showEdit: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <div class="note-actions"> + <reply-button v-if="showReply" ref="replyButton" @startReplying="$emit('startReplying')" /> + <gl-button + v-if="showEdit" + v-gl-tooltip + data-testid="edit-work-item-note" + data-track-action="click_button" + data-track-label="edit_button" + category="tertiary" + icon="pencil" + :title="$options.i18n.editButtonText" + :aria-label="$options.i18n.editButtonText" + @click="$emit('startEditing')" + /> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue index dcee8750f81..95397b58925 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue @@ -3,6 +3,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { + name: 'WorkItemNoteBody', directives: { SafeHtml, }, @@ -12,12 +13,22 @@ export default { required: true, }, }, - mounted() { - this.renderGFM(); + watch: { + 'note.bodyHtml': { + immediate: true, + async handler(newVal, oldVal) { + if (newVal === oldVal) { + return; + } + await this.$nextTick(); + this.renderGFM(); + }, + }, }, methods: { renderGFM() { renderGFM(this.$refs['note-body']); + gl?.lazyLoader?.searchLazyImages(); }, }, safeHtmlConfig: { diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue new file mode 100644 index 00000000000..46f61ccd204 --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue @@ -0,0 +1,53 @@ +<script> +import { GlAvatar } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import NoteHeader from '~/notes/components/note_header.vue'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; + +export default { + name: 'WorkItemNoteReplying', + components: { + TimelineEntryItem, + GlAvatar, + NoteHeader, + }, + directives: { + SafeHtml, + }, + safeHtmlConfig: { + ADD_TAGS: ['use', 'gl-emoji', 'copy-code'], + }, + constantOptions: { + avatarUrl: window.gon.current_user_avatar_url, + }, + props: { + body: { + type: String, + required: false, + default: '', + }, + }, + computed: { + author() { + return { + avatarUrl: window.gon.current_user_avatar_url, + id: window.gon.current_user_id, + name: window.gon.current_user_fullname, + username: window.gon.current_username, + }; + }, + }, +}; +</script> + +<template> + <timeline-entry-item class="note note-wrapper note-comment gl-p-4 being-posted"> + <div class="timeline-avatar gl-float-left"> + <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" /> + </div> + <div class="note-header"> + <note-header :author="author" /> + </div> + <div ref="note-body" v-safe-html:[$options.safeHtmlConfig]="body" class="note-body"></div> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue index 3ef4a16bc57..3ef4a16bc57 100644 --- a/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue new file mode 100644 index 00000000000..355f17e970b --- /dev/null +++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue @@ -0,0 +1,80 @@ +<script> +import { GlAlert, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlAlert, + GlButton, + }, + props: { + error: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isOpen: true, + }; + }, + computed: { + toggleIcon() { + return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; + }, + toggleLabel() { + return this.isOpen ? __('Collapse') : __('Expand'); + }, + }, + methods: { + hide() { + this.isOpen = false; + }, + show() { + this.isOpen = true; + }, + toggle() { + this.isOpen = !this.isOpen; + }, + }, +}; +</script> + +<template> + <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4"> + <div + class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" + :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" + > + <div class="gl-display-flex gl-flex-grow-1"> + <h5 class="gl-m-0 gl-line-height-24"> + <slot name="header"></slot> + </h5> + <slot name="header-suffix"></slot> + </div> + <slot name="header-right"></slot> + <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3"> + <gl-button + category="tertiary" + size="small" + :icon="toggleIcon" + :aria-label="toggleLabel" + data-testid="widget-toggle" + @click="toggle" + /> + </div> + </div> + <gl-alert v-if="error" variant="danger" @dismiss="$emit('dismissAlert')"> + {{ error }} + </gl-alert> + <div + v-if="isOpen" + class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + :class="{ 'gl-p-5 gl-pb-3': !error }" + data-testid="widget-body" + > + <slot name="body"></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index c2980405a19..fc4c05d96b2 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -313,6 +313,7 @@ export default { :view-only="!canUpdate" :allow-clear-all="isEditing" class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2" + data-testid="work-item-assignees-input" @input="handleAssigneesInput" @text-input="debouncedSearchKeyUpdate" @focus="handleFocus" diff --git a/app/assets/javascripts/work_items/components/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/work_item_comment_form.vue deleted file mode 100644 index 65042f1431d..00000000000 --- a/app/assets/javascripts/work_items/components/work_item_comment_form.vue +++ /dev/null @@ -1,228 +0,0 @@ -<script> -import { GlAvatar, GlButton } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; -import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import { __, s__ } from '~/locale'; -import Tracking from '~/tracking'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import { getWorkItemQuery, getWorkItemNotesQuery } from '../utils'; -import createNoteMutation from '../graphql/create_work_item_note.mutation.graphql'; -import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; -import WorkItemNoteSignedOut from './work_item_note_signed_out.vue'; -import WorkItemCommentLocked from './work_item_comment_locked.vue'; - -export default { - constantOptions: { - markdownDocsPath: helpPagePath('user/markdown'), - avatarUrl: window.gon.current_user_avatar_url, - }, - components: { - GlAvatar, - GlButton, - MarkdownEditor, - WorkItemNoteSignedOut, - WorkItemCommentLocked, - }, - mixins: [glFeatureFlagMixin(), Tracking.mixin()], - props: { - workItemId: { - type: String, - required: true, - }, - fullPath: { - type: String, - required: true, - }, - fetchByIid: { - type: Boolean, - required: false, - default: false, - }, - queryVariables: { - type: Object, - required: true, - }, - }, - data() { - return { - workItem: {}, - isEditing: false, - isSubmitting: false, - isSubmittingWithKeydown: false, - commentText: '', - }; - }, - apollo: { - workItem: { - query() { - return getWorkItemQuery(this.fetchByIid); - }, - variables() { - return this.queryVariables; - }, - update(data) { - return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; - }, - skip() { - return !this.queryVariables.id && !this.queryVariables.iid; - }, - error() { - this.$emit('error', i18n.fetchError); - }, - }, - }, - computed: { - signedIn() { - return Boolean(window.gon.current_user_id); - }, - autosaveKey() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `${this.workItemId}-comment`; - }, - canEdit() { - // maybe this should use `NotePermissions.updateNote`, but if - // we don't have any notes yet, that permission isn't on WorkItem - return Boolean(this.workItem?.userPermissions?.updateWorkItem); - }, - tracking() { - return { - category: TRACKING_CATEGORY_SHOW, - label: 'item_comment', - property: `type_${this.workItemType}`, - }; - }, - workItemType() { - return this.workItem?.workItemType?.name; - }, - markdownPreviewPath() { - return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${ - this.workItemType - }`; - }, - isProjectArchived() { - return this.workItem?.project?.archived; - }, - }, - methods: { - startEditing() { - this.isEditing = true; - this.commentText = getDraft(this.autosaveKey) || ''; - }, - async cancelEditing() { - if (this.commentText) { - const msg = s__('WorkItem|Are you sure you want to cancel editing?'); - - const confirmed = await confirmAction(msg, { - primaryBtnText: __('Discard changes'), - cancelBtnText: __('Continue editing'), - }); - - if (!confirmed) { - return; - } - } - - this.isEditing = false; - clearDraft(this.autosaveKey); - }, - async updateWorkItem(event = {}) { - const { key } = event; - - if (key) { - this.isSubmittingWithKeydown = true; - } - - this.isSubmitting = true; - - try { - this.track('add_work_item_comment'); - - const { - data: { createNote }, - } = await this.$apollo.mutate({ - mutation: createNoteMutation, - variables: { - input: { - noteableId: this.workItem.id, - body: this.commentText, - }, - }, - }); - - if (createNote.errors?.length) { - throw new Error(createNote.errors[0]); - } - - const client = this.$apollo.provider.defaultClient; - client.refetchQueries({ - include: [getWorkItemNotesQuery(this.fetchByIid)], - }); - - this.isEditing = false; - clearDraft(this.autosaveKey); - } catch (error) { - this.$emit('error', error.message); - Sentry.captureException(error); - } - - this.isSubmitting = false; - }, - setCommentText(newText) { - this.commentText = newText; - updateDraft(this.autosaveKey, this.commentText); - }, - }, -}; -</script> - -<template> - <li class="timeline-entry"> - <work-item-note-signed-out v-if="!signedIn" /> - <work-item-comment-locked - v-else-if="!canEdit" - :work-item-type="workItemType" - :is-project-archived="isProjectArchived" - /> - <div v-else class="gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap"> - <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" /> - <form v-if="isEditing" class="common-note-form gfm-form js-main-target-form gl-flex-grow-1"> - <markdown-editor - class="gl-mb-3" - :value="commentText" - :render-markdown-path="markdownPreviewPath" - :markdown-docs-path="$options.constantOptions.markdownDocsPath" - :form-field-aria-label="__('Add a comment')" - :form-field-placeholder="__('Write a comment or drag your files here…')" - form-field-id="work-item-add-comment" - form-field-name="work-item-add-comment" - enable-autocomplete - autofocus - use-bottom-toolbar - @input="setCommentText" - @keydown.meta.enter="updateWorkItem" - @keydown.ctrl.enter="updateWorkItem" - @keydown.esc="cancelEditing" - /> - <gl-button - category="primary" - variant="confirm" - :loading="isSubmitting" - @click="updateWorkItem" - >{{ __('Comment') }} - </gl-button> - <gl-button category="tertiary" class="gl-ml-3" @click="cancelEditing" - >{{ __('Cancel') }} - </gl-button> - </form> - <gl-button - v-else - class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!" - @click="startEditing" - >{{ __('Add a comment') }}</gl-button - > - </div> - </li> -</template> diff --git a/app/assets/javascripts/work_items/components/work_item_created_updated.vue b/app/assets/javascripts/work_items/components/work_item_created_updated.vue new file mode 100644 index 00000000000..d1a707f2a8a --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_created_updated.vue @@ -0,0 +1,115 @@ +<script> +import { GlAvatarLink, GlSprintf } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { getWorkItemQuery } from '../utils'; + +export default { + components: { + GlAvatarLink, + GlSprintf, + TimeAgoTooltip, + }, + props: { + fetchByIid: { + type: Boolean, + required: true, + }, + workItemId: { + type: String, + required: false, + default: null, + }, + workItemIid: { + type: String, + required: false, + default: null, + }, + fullPath: { + type: String, + required: false, + default: null, + }, + }, + computed: { + createdAt() { + return this.workItem?.createdAt || ''; + }, + updatedAt() { + return this.workItem?.updatedAt || ''; + }, + author() { + return this.workItem?.author ?? {}; + }, + authorId() { + return getIdFromGraphQLId(this.author.id); + }, + queryVariables() { + return this.fetchByIid + ? { + fullPath: this.fullPath, + iid: this.workItemIid, + } + : { + id: this.workItemId, + }; + }, + }, + apollo: { + workItem: { + query() { + return getWorkItemQuery(this.fetchByIid); + }, + variables() { + return this.queryVariables; + }, + skip() { + return !this.workItemId && !this.workItemIid; + }, + update(data) { + const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + return workItem ?? {}; + }, + }, + }, +}; +</script> + +<template> + <div class="gl-mb-3"> + <span data-testid="work-item-created"> + <gl-sprintf v-if="author.name" :message="__('Created %{timeAgo} by %{author}')"> + <template #timeAgo> + <time-ago-tooltip :time="createdAt" /> + </template> + <template #author> + <gl-avatar-link + class="js-user-link gl-text-body gl-font-weight-bold" + :title="author.name" + :data-user-id="authorId" + :href="author.webUrl" + > + {{ author.name }} + </gl-avatar-link> + </template> + </gl-sprintf> + <gl-sprintf v-else-if="createdAt" :message="__('Created %{timeAgo}')"> + <template #timeAgo> + <time-ago-tooltip :time="createdAt" /> + </template> + </gl-sprintf> + </span> + + <span + v-if="updatedAt" + class="gl-ml-5 gl-display-none gl-sm-display-inline-block" + data-testid="work-item-updated" + > + <gl-sprintf :message="__('Updated %{timeAgo}')"> + <template #timeAgo> + <time-ago-tooltip :time="updatedAt" /> + </template> + </gl-sprintf> + </span> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue index 07da0279b41..399c220bc96 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -10,7 +10,7 @@ import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import { getWorkItemQuery } from '../utils'; +import { getWorkItemQuery, autocompleteDataSources, markdownPreviewPath } from '../utils'; import workItemDescriptionSubscription from '../graphql/work_item_description.subscription.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants'; @@ -46,7 +46,8 @@ export default { required: true, }, }, - markdownDocsPath: helpPagePath('user/markdown'), + markdownDocsPath: helpPagePath('user/project/quick_actions'), + quickActionsDocsPath: helpPagePath('user/project/quick_actions'), data() { return { workItem: {}, @@ -56,6 +57,12 @@ export default { descriptionText: '', descriptionHtml: '', conflictedDescription: '', + formFieldProps: { + 'aria-label': __('Description'), + placeholder: __('Write a comment or drag your files here…'), + id: 'work-item-description', + name: 'work-item-description', + }, }; }, apollo: { @@ -134,9 +141,10 @@ export default { return this.workItemDescription?.lastEditedBy?.webPath; }, markdownPreviewPath() { - return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${ - this.workItemType - }`; + return markdownPreviewPath(this.fullPath, this.workItem.iid); + }, + autocompleteDataSources() { + return autocompleteDataSources(this.fullPath, this.workItem.iid); }, }, methods: { @@ -241,11 +249,11 @@ export default { :value="descriptionText" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" - :form-field-aria-label="__('Description')" - :form-field-placeholder="__('Write a comment or drag your files here…')" - form-field-id="work-item-description" - form-field-name="work-item-description" + :form-field-props="formFieldProps" + :quick-actions-docs-path="$options.quickActionsDocsPath" + :autocomplete-data-sources="autocompleteDataSources" enable-autocomplete + supports-quick-actions init-on-autofocus use-bottom-toolbar @input="setDescriptionText" @@ -259,19 +267,19 @@ export default { :is-submitting="isSubmitting" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" + :quick-actions-docs-path="$options.quickActionsDocsPath" + :autocomplete-data-sources="autocompleteDataSources" class="gl-px-3 bordered-box gl-mt-5" > <template #textarea> <textarea - id="work-item-description" + v-bind="formFieldProps" ref="textarea" v-model="descriptionText" :disabled="isSubmitting" class="note-textarea js-gfm-input js-autosize markdown-area" dir="auto" - data-supports-quick-actions="false" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" + data-supports-quick-actions="true" @keydown.meta.enter="updateWorkItem" @keydown.ctrl.enter="updateWorkItem" @keydown.exact.esc.stop="cancelEditing" diff --git a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue index d58983c013d..9a2cdc1c172 100644 --- a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue +++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue @@ -47,6 +47,7 @@ export default { await this.$nextTick(); renderGFM(this.$refs['gfm-content']); + gl?.lazyLoader?.searchLazyImages(); if (this.canEdit) { this.checkboxes = this.$el.querySelectorAll('.task-list-item-checkbox'); 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 ade954b2a7f..262c093a1d0 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -19,7 +19,7 @@ import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url import { isPositiveInteger } from '~/lib/utils/number_utils'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import { sprintfWorkItem, @@ -51,6 +51,7 @@ import WorkItemTree from './work_item_links/work_item_tree.vue'; import WorkItemActions from './work_item_actions.vue'; import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; +import WorkItemCreatedUpdated from './work_item_created_updated.vue'; import WorkItemDescription from './work_item_description.vue'; import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; @@ -74,6 +75,7 @@ export default { GlEmptyState, WorkItemAssignees, WorkItemActions, + WorkItemCreatedUpdated, WorkItemDescription, WorkItemDueDate, WorkItemLabels, @@ -123,8 +125,9 @@ export default { workItem: {}, updateInProgress: false, modalWorkItemId: isPositiveInteger(workItemId) - ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId) + ? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId) : null, + modalWorkItemIid: getParameterByName('work_item_iid'), }; }, apollo: { @@ -136,7 +139,7 @@ export default { return this.queryVariables; }, skip() { - return !this.workItemId; + return !this.workItemId && !this.workItemIid; }, update(data) { const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; @@ -290,7 +293,10 @@ export default { return this.isWidgetPresent(WIDGET_TYPE_NOTES); }, fetchByIid() { - return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); + return ( + (this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'))) || + false + ); }, queryVariables() { return this.fetchByIid @@ -310,8 +316,8 @@ export default { }, }, mounted() { - if (this.modalWorkItemId) { - this.openInModal(undefined, { id: this.modalWorkItemId }); + if (this.modalWorkItemId || this.modalWorkItemIid) { + this.openInModal(undefined, { id: this.modalWorkItemId, iid: this.modalWorkItemIid }); } }, methods: { @@ -439,24 +445,33 @@ export default { Sentry.captureException(error); } }, - updateUrl(modalWorkItemId) { + updateUrl(modalWorkItem) { + const params = this.fetchByIid + ? { work_item_iid: modalWorkItem?.iid } + : { work_item_id: getIdFromGraphQLId(modalWorkItem?.id) }; + updateHistory({ - url: setUrlParams({ work_item_id: getIdFromGraphQLId(modalWorkItemId) }), + url: setUrlParams(params), replace: true, }); }, openInModal(event, modalWorkItem) { + if (!this.workItemsMvc2Enabled) { + return; + } + if (event) { event.preventDefault(); - this.updateUrl(modalWorkItem.id); + this.updateUrl(modalWorkItem); } if (this.isModal) { - this.$emit('update-modal', event, modalWorkItem.id); + this.$emit('update-modal', event, modalWorkItem); return; } this.modalWorkItemId = modalWorkItem.id; + this.modalWorkItemIid = modalWorkItem.iid; this.$refs.modal.show(); }, }, @@ -559,6 +574,12 @@ export default { :can-update="canUpdate" @error="updateError = $event" /> + <work-item-created-updated + :work-item-id="workItem.id" + :work-item-iid="workItemIid" + :full-path="fullPath" + :fetch-by-iid="fetchByIid" + /> <work-item-state :work-item="workItem" :work-item-parent-id="workItemParentId" @@ -696,6 +717,7 @@ export default { v-if="!isModal" ref="modal" :work-item-id="modalWorkItemId" + :work-item-iid="modalWorkItemIid" :show="true" @close="updateUrl" /> 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 faea80a9de8..1b8e97bf717 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 @@ -50,12 +50,16 @@ export default { return { error: undefined, updatedWorkItemId: null, + updatedWorkItemIid: null, }; }, computed: { displayedWorkItemId() { return this.updatedWorkItemId || this.workItemId; }, + displayedWorkItemIid() { + return this.updatedWorkItemIid || this.workItemIid; + }, }, methods: { deleteWorkItem() { @@ -122,6 +126,7 @@ export default { }, closeModal() { this.updatedWorkItemId = null; + this.updatedWorkItemIid = null; this.error = ''; this.$emit('close'); }, @@ -134,9 +139,10 @@ export default { show() { this.$refs.modal.show(); }, - updateModal($event, workItemId) { - this.updatedWorkItemId = workItemId; - this.$emit('update-modal', $event, workItemId); + updateModal($event, workItem) { + this.updatedWorkItemId = workItem.id; + this.updatedWorkItemIid = workItem.iid; + this.$emit('update-modal', $event, workItem); }, }, }; @@ -150,6 +156,7 @@ export default { modal-id="work-item-detail-modal" header-class="gl-p-0 gl-pb-2!" scrollable + data-testid="work-item-detail-modal" @hide="closeModal" > <gl-alert v-if="error" variant="danger" @dismiss="error = false"> @@ -160,7 +167,7 @@ export default { is-modal :work-item-parent-id="issueGid" :work-item-id="displayedWorkItemId" - :work-item-iid="workItemIid" + :work-item-iid="displayedWorkItemIid" class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolate" @close="hide" @deleteWorkItem="deleteWorkItem" diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue index 9ee302855c7..03c5b7096b2 100644 --- a/app/assets/javascripts/work_items/components/work_item_due_date.vue +++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue @@ -215,6 +215,7 @@ export default { ref="startDatePicker" v-model="dirtyStartDate" container="body" + data-testid="work-item-start-date-picker" :disabled="isDatepickerDisabled" :input-id="$options.startDateInputId" show-clear-button @@ -240,6 +241,7 @@ export default { ref="dueDatePicker" v-model="dirtyDueDate" container="body" + data-testid="work-item-due-date-picker" :disabled="isDatepickerDisabled" :input-id="$options.dueDateInputId" :min-date="dirtyStartDate" diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue index 45fb0f7f21a..8e9e1def0b9 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -19,7 +19,13 @@ import { } from '../constants'; function isTokenSelectorElement(el) { - return el?.classList.contains('gl-label-close') || el?.classList.contains('dropdown-item'); + return ( + el?.classList.contains('gl-label-close') || + el?.classList.contains('dropdown-item') || + // TODO: replace this logic when we have a class added to clear-all button in GitLab UI + (el?.classList.contains('gl-button') && + el?.closest('.form-control')?.classList.contains('gl-token-selector')) + ); } function addClass(el) { @@ -146,7 +152,17 @@ export default { watch: { labels(newVal) { if (!this.isEditing) { - this.localLabels = newVal.map(addClass); + // remove labels that aren't in list from server + this.localLabels = this.localLabels.filter((label) => + newVal.find((l) => l.id === label.id), + ); + + // add any that we don't have to the end + const labelsToAdd = newVal + .map(addClass) + .filter((label) => !this.localLabels.find((l) => l.id === label.id)); + + this.localLabels = this.localLabels.concat(labelsToAdd); } }, }, @@ -163,10 +179,11 @@ export default { this.setLabels(); }, async setLabels() { - if (this.addLabelIds.length === 0 && this.removeLabelIds.length === 0) return; - this.searchKey = ''; this.isEditing = false; + + if (this.addLabelIds.length === 0 && this.removeLabelIds.length === 0) return; + try { const { data: { @@ -214,18 +231,23 @@ export default { this.searchStarted = true; }, async focusTokenSelector(labels) { - const labelsToAdd = without(labels, ...this.localLabels).map((label) => label.id); - const labelsToRemove = without(this.localLabels, ...labels).map((label) => label.id); + const labelsToAdd = without(labels, ...this.localLabels); + const labelIdsToAdd = labelsToAdd.map((label) => label.id); + const labelIdsToRemove = without(this.localLabels, ...labels).map((label) => label.id); - if (labelsToAdd.length > 0) { - this.addLabelIds.push(...labelsToAdd); + if (labelIdsToAdd.length > 0) { + this.addLabelIds.push(...labelIdsToAdd); } - if (labelsToRemove.length > 0) { - this.removeLabelIds.push(...labelsToRemove); + if (labelIdsToRemove.length > 0) { + this.removeLabelIds.push(...labelIdsToRemove); } - this.localLabels = labels; + if (labels.length === 0) { + this.localLabels = []; + } else { + this.localLabels = this.localLabels.concat(labelsToAdd); + } this.handleFocus(); await this.$nextTick(); @@ -265,7 +287,9 @@ export default { :dropdown-items="searchLabels" :loading="isLoading" :view-only="!canUpdate" + :allow-clear-all="isEditing" class="gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!" + data-testid="work-item-labels-input" :class="{ 'gl-hover-border-gray-200': canUpdate }" @input="focusTokenSelector" @text-input="debouncedSearchKeyUpdate" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index b078711ec5d..e8578a6d49a 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -1,46 +1,39 @@ <script> -import { - GlButton, - GlDropdown, - GlDropdownItem, - GlIcon, - GlAlert, - GlLoadingIcon, - GlTooltipDirective, -} from '@gitlab/ui'; -import { produce } from 'immer'; +import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { s__ } from '~/locale'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import { isMetaKey, parseBoolean } from '~/lib/utils/common_utils'; -import { setUrlParams, updateHistory, getParameterByName } from '~/lib/utils/url_utility'; +import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { FORM_TYPES, WIDGET_ICONS, - WORK_ITEM_STATUS_TEXT, WIDGET_TYPE_HIERARCHY, + WORK_ITEM_STATUS_TEXT, } from '../../constants'; import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; +import addHierarchyChildMutation from '../../graphql/add_hierarchy_child.mutation.graphql'; +import removeHierarchyChildMutation from '../../graphql/remove_hierarchy_child.mutation.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import workItemQuery from '../../graphql/work_item.query.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import WidgetWrapper from '../widget_wrapper.vue'; import WorkItemDetailModal from '../work_item_detail_modal.vue'; import WorkItemLinkChild from './work_item_link_child.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; export default { components: { - GlButton, GlDropdown, GlDropdownItem, GlIcon, - GlAlert, GlLoadingIcon, + WidgetWrapper, WorkItemLinkChild, WorkItemLinksForm, WorkItemDetailModal, @@ -105,13 +98,13 @@ export default { data() { return { isShownAddForm: false, - isOpen: true, activeChild: {}, activeToast: null, prefetchedWorkItem: null, error: undefined, parentIssue: null, formType: null, + workItem: null, }; }, computed: { @@ -137,14 +130,8 @@ export default { isChildrenEmpty() { return this.children?.length === 0; }, - toggleIcon() { - return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; - }, - toggleLabel() { - return this.isOpen ? s__('WorkItem|Collapse tasks') : s__('WorkItem|Expand tasks'); - }, issuableGid() { - return this.issuableId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issuableId) : null; + return this.issuableId ? convertToGraphQLId(TYPENAME_WORK_ITEM, this.issuableId) : null; }, isLoading() { return this.$apollo.queries.workItem.loading; @@ -168,7 +155,7 @@ export default { } else { const workItemId = getParameterByName('work_item_id'); if (workItemId) { - params.id = convertToGraphQLId(TYPE_WORK_ITEM, workItemId); + params.id = convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId); } } return params; @@ -180,11 +167,8 @@ export default { } }, methods: { - toggle() { - this.isOpen = !this.isOpen; - }, showAddForm(formType) { - this.isOpen = true; + this.$refs.wrapper.show(); this.isShownAddForm = true; this.formType = formType; this.$nextTick(() => { @@ -194,10 +178,6 @@ export default { hideAddForm() { this.isShownAddForm = false; }, - addChild(child) { - const { defaultClient: client } = this.$apollo.provider.clients; - this.toggleChildFromCache(child, child.id, client); - }, openChild(child, e) { if (isMetaKey(e)) { return; @@ -211,9 +191,8 @@ export default { this.activeChild = {}; this.updateWorkItemIdUrlQuery(); }, - handleWorkItemDeleted(childId) { - const { defaultClient: client } = this.$apollo.provider.clients; - this.toggleChildFromCache(null, childId, client); + handleWorkItemDeleted(child) { + this.removeHierarchyChild(child); this.activeToast = this.$toast.show(s__('WorkItem|Task deleted')); }, updateWorkItemIdUrlQuery({ id, iid } = {}) { @@ -222,38 +201,31 @@ export default { : { work_item_id: getIdFromGraphQLId(id) }; updateHistory({ url: setUrlParams(params), replace: true }); }, - toggleChildFromCache(workItem, childId, store) { - const sourceData = store.readQuery({ - query: getWorkItemLinksQuery, - variables: { id: this.issuableGid }, - }); - - const newData = produce(sourceData, (draftState) => { - const widgetHierarchy = draftState.workItem.widgets.find( - (widget) => widget.type === WIDGET_TYPE_HIERARCHY, - ); - - const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId); - - if (index >= 0) { - widgetHierarchy.children.nodes.splice(index, 1); - } else { - widgetHierarchy.children.nodes.push(workItem); - } + async addHierarchyChild(workItem) { + return this.$apollo.mutate({ + mutation: addHierarchyChildMutation, + variables: { id: this.issuableGid, workItem }, }); - - store.writeQuery({ - query: getWorkItemLinksQuery, - variables: { id: this.issuableGid }, - data: newData, + }, + async removeHierarchyChild(workItem) { + return this.$apollo.mutate({ + mutation: removeHierarchyChildMutation, + variables: { id: this.issuableGid, workItem }, }); }, async updateWorkItem(workItem, childId, parentId) { - return this.$apollo.mutate({ + const response = await this.$apollo.mutate({ mutation: updateWorkItemMutation, variables: { input: { id: childId, hierarchyWidget: { parentId } } }, - update: (store) => this.toggleChildFromCache(workItem, childId, store), }); + + if (parentId === null) { + await this.removeHierarchyChild(workItem); + } else { + await this.addHierarchyChild(workItem); + } + + return response; }, async undoChildRemoval(workItem, childId) { const { data } = await this.updateWorkItem(workItem, childId, this.issuableGid); @@ -263,7 +235,7 @@ export default { } }, async removeChild(childId) { - const { data } = await this.updateWorkItem(null, childId, null); + const { data } = await this.updateWorkItem({ id: childId }, childId, null); if (data.workItemUpdate.errors.length === 0) { this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { @@ -323,24 +295,23 @@ export default { </script> <template> - <div - class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4" + <widget-wrapper + ref="wrapper" + :error="error" data-testid="work-item-links" + @dismissAlert="error = undefined" > - <div - class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" - :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" - > - <div class="gl-display-flex gl-flex-grow-1"> - <h5 class="gl-m-0 gl-line-height-24">{{ $options.i18n.title }}</h5> - <span - class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3" - data-testid="children-count" - > - <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-secondary" /> - {{ childrenCountLabel }} - </span> - </div> + <template #header>{{ $options.i18n.title }}</template> + <template #header-suffix> + <span + class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3" + data-testid="children-count" + > + <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-secondary" /> + {{ childrenCountLabel }} + </span> + </template> + <template #header-right> <gl-dropdown v-if="canUpdate" right @@ -361,26 +332,8 @@ export default { {{ $options.i18n.addChildOptionLabel }} </gl-dropdown-item> </gl-dropdown> - <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3"> - <gl-button - category="tertiary" - size="small" - :icon="toggleIcon" - :aria-label="toggleLabel" - data-testid="toggle-links" - @click="toggle" - /> - </div> - </div> - <gl-alert v-if="error && !isLoading" variant="danger" @dismiss="error = undefined"> - {{ error }} - </gl-alert> - <div - v-if="isOpen" - class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" - :class="{ 'gl-p-5 gl-pb-3': !error }" - data-testid="links-body" - > + </template> + <template #body> <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" /> <template v-else> @@ -401,7 +354,7 @@ export default { :form-type="formType" :parent-work-item-type="workItem.workItemType.name" @cancel="hideAddForm" - @addWorkItemChild="addChild" + @addWorkItemChild="addHierarchyChild" /> <work-item-link-child v-for="child in children" @@ -420,9 +373,9 @@ export default { :work-item-id="activeChild.id" :work-item-iid="activeChild.iid" @close="closeModal" - @workItemDeleted="handleWorkItemDeleted(activeChild.id)" + @workItemDeleted="handleWorkItemDeleted(activeChild)" /> </template> - </div> - </div> + </template> + </widget-wrapper> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index d79aaab38f2..5169a77dd33 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -13,7 +13,6 @@ import { debounce } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; @@ -42,7 +41,6 @@ export default { GlFormCheckbox, GlTooltip, }, - mixins: [glFeatureFlagMixin()], inject: ['projectPath', 'hasIterationsFeature'], props: { issuableGid: { @@ -161,12 +159,6 @@ export default { return workItemInput; }, - workItemsMvcEnabled() { - return this.glFeatures.workItemsMvc; - }, - workItemsMvc2Enabled() { - return this.glFeatures.workItemsMvc2; - }, isCreateForm() { return this.formType === FORM_TYPES.create; }, diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue index 81e2bb76900..aa12df424f1 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -1,9 +1,7 @@ <script> -import { GlButton } from '@gitlab/ui'; import { isEmpty } from 'lodash'; -import { __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { parseBoolean } from '~/lib/utils/common_utils'; import { getParameterByName } from '~/lib/utils/url_utility'; @@ -19,6 +17,7 @@ import { } from '../../constants'; import workItemQuery from '../../graphql/work_item.query.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import WidgetWrapper from '../widget_wrapper.vue'; import OkrActionsSplitButton from './okr_actions_split_button.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; import WorkItemLinkChild from './work_item_link_child.vue'; @@ -29,8 +28,8 @@ export default { WORK_ITEM_TYPE_ENUM_OBJECTIVE, WORK_ITEM_TYPE_ENUM_KEY_RESULT, components: { - GlButton, OkrActionsSplitButton, + WidgetWrapper, WorkItemLinksForm, WorkItemLinkChild, }, @@ -72,20 +71,12 @@ export default { data() { return { isShownAddForm: false, - isOpen: true, - error: null, formType: null, childType: null, prefetchedWorkItem: null, }; }, computed: { - toggleIcon() { - return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; - }, - toggleLabel() { - return this.isOpen ? __('Collapse') : __('Expand'); - }, fetchByIid() { return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); }, @@ -109,7 +100,7 @@ export default { } else { const workItemId = getParameterByName('work_item_id'); if (workItemId) { - params.id = convertToGraphQLId(TYPE_WORK_ITEM, workItemId); + params.id = convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId); } } return params; @@ -121,11 +112,8 @@ export default { } }, methods: { - toggle() { - this.isOpen = !this.isOpen; - }, showAddForm(formType, childType) { - this.isOpen = true; + this.$refs.wrapper.show(); this.isShownAddForm = true; this.formType = formType; this.childType = childType; @@ -176,19 +164,11 @@ export default { </script> <template> - <div - class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4" - data-testid="work-item-tree" - > - <div - class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" - :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" - > - <div class="gl-display-flex gl-flex-grow-1"> - <h5 class="gl-m-0 gl-line-height-24"> - {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }} - </h5> - </div> + <widget-wrapper ref="wrapper" data-testid="work-item-tree"> + <template #header> + {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }} + </template> + <template #header-right> <okr-actions-split-button @showCreateObjectiveForm=" showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE) @@ -203,24 +183,9 @@ export default { showAddForm($options.FORM_TYPES.add, $options.WORK_ITEM_TYPE_ENUM_KEY_RESULT) " /> - <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3"> - <gl-button - category="tertiary" - size="small" - :icon="toggleIcon" - :aria-label="toggleLabel" - data-testid="toggle-tree" - @click="toggle" - /> - </div> - </div> - <div - v-if="isOpen" - class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" - :class="{ 'gl-p-5 gl-pb-3': !error }" - data-testid="tree-body" - > - <div v-if="!isShownAddForm && !error && children.length === 0" data-testid="tree-empty"> + </template> + <template #body> + <div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty"> <p class="gl-mb-3"> {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} </p> @@ -253,6 +218,6 @@ export default { @removeChild="$emit('removeChild', $event)" @click="$emit('show-modal', $event, $event.childItem || child)" /> - </div> - </div> + </template> + </widget-wrapper> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue index a59767d8b70..02b94c5331c 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -1,13 +1,16 @@ <script> -import { GlSkeletonLoader } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlSkeletonLoader, GlModal } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { s__, __ } from '~/locale'; +import { TYPENAME_DISCUSSION, TYPENAME_NOTE } from '~/graphql_shared/constants'; import SystemNote from '~/work_items/components/notes/system_note.vue'; import ActivityFilter from '~/work_items/components/notes/activity_filter.vue'; import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants'; import { ASC, DESC } from '~/notes/constants'; import { getWorkItemNotesQuery } from '~/work_items/utils'; -import WorkItemNote from '~/work_items/components/notes/work_item_note.vue'; -import WorkItemCommentForm from './work_item_comment_form.vue'; +import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue'; +import deleteNoteMutation from '../graphql/notes/delete_work_item_notes.mutation.graphql'; +import WorkItemAddNote from './notes/work_item_add_note.vue'; export default { i18n: { @@ -20,10 +23,11 @@ export default { }, components: { GlSkeletonLoader, + GlModal, ActivityFilter, SystemNote, - WorkItemCommentForm, - WorkItemNote, + WorkItemAddNote, + WorkItemDiscussion, }, props: { workItemId: { @@ -50,38 +54,52 @@ export default { }, data() { return { - notesArray: [], isLoadingMore: false, perPage: DEFAULT_PAGE_SIZE_NOTES, sortOrder: ASC, - changeNotesSortOrderAfterLoading: false, + noteToDelete: null, }; }, computed: { initialLoading() { return this.$apollo.queries.workItemNotes.loading && !this.isLoadingMore; }, - pageInfo() { - return this.workItemNotes?.pageInfo; - }, avatarUrl() { return window.gon.current_user_avatar_url; }, + pageInfo() { + return this.workItemNotes?.pageInfo; + }, hasNextPage() { return this.pageInfo?.hasNextPage; }, - showInitialLoader() { - return this.initialLoading || this.changeNotesSortOrderAfterLoading; - }, - showTimeline() { - return !this.changeNotesSortOrderAfterLoading; - }, showLoadingMoreSkeleton() { return this.isLoadingMore && !this.changeNotesSortOrderAfterLoading; }, disableActivityFilter() { return this.initialLoading || this.isLoadingMore; }, + formAtTop() { + return this.sortOrder === DESC; + }, + workItemCommentFormProps() { + return { + queryVariables: this.queryVariables, + fullPath: this.fullPath, + workItemId: this.workItemId, + fetchByIid: this.fetchByIid, + workItemType: this.workItemType, + sortOrder: this.sortOrder, + }; + }, + notesArray() { + const notes = this.workItemNotes?.nodes || []; + + if (this.sortOrder === DESC) { + return [...notes].reverse(); + } + return notes; + }, }, apollo: { workItemNotes: { @@ -104,8 +122,6 @@ export default { : data.workItem?.widgets; const discussionNodes = workItemWidgets.find((widget) => widget.type === 'NOTES')?.discussions || []; - this.notesArray = discussionNodes?.nodes || []; - this.updateSortingOrderIfApplicable(); return discussionNodes; }, skip() { @@ -115,6 +131,8 @@ export default { this.$emit('error', i18n.fetchError); }, result() { + this.updateSortingOrderIfApplicable(); + if (this.hasNextPage) { this.fetchMoreNotes(); } @@ -122,6 +140,11 @@ export default { }, }, methods: { + getDiscussionKey(discussion) { + // discussion key is important like this since after first comment changes + const discussionId = discussion.notes.nodes[0].id; + return discussionId.split('/')[discussionId.split('/').length - 1]; + }, isSystemNote(note) { return note.notes.nodes[0].system; }, @@ -136,17 +159,8 @@ export default { this.changeNotesSortOrder(DESC); } }, - updateInitialSortedOrder(direction) { - this.sortOrder = direction; - // when the direction is reverse , we need to load all since the sorting is on the frontend - if (direction === DESC) { - this.changeNotesSortOrderAfterLoading = true; - } - }, changeNotesSortOrder(direction) { this.sortOrder = direction; - this.notesArray = [...this.notesArray].reverse(); - this.changeNotesSortOrderAfterLoading = false; }, async fetchMoreNotes() { this.isLoadingMore = true; @@ -163,8 +177,44 @@ export default { }) .catch((error) => this.$emit('error', error.message)); this.isLoadingMore = false; - if (this.changeNotesSortOrderAfterLoading && !this.hasNextPage) { - this.changeNotesSortOrder(this.sortOrder); + }, + showDeleteNoteModal(note, discussion) { + const isLastNote = discussion.notes.nodes.length === 1; + this.$refs.deleteNoteModal.show(); + this.noteToDelete = { ...note, isLastNote }; + }, + cancelDeletingNote() { + this.noteToDelete = null; + }, + async deleteNote() { + try { + const { id, isLastNote, discussion } = this.noteToDelete; + await this.$apollo.mutate({ + mutation: deleteNoteMutation, + variables: { + input: { + id, + }, + }, + update(cache) { + const deletedObject = isLastNote + ? { __typename: TYPENAME_DISCUSSION, id: discussion.id } + : { __typename: TYPENAME_NOTE, id }; + cache.modify({ + id: cache.identify(deletedObject), + fields: (_, { DELETE }) => DELETE, + }); + }, + optimisticResponse: { + destroyNote: { + note: null, + __typename: 'DestroyNotePayload', + }, + }, + }); + } catch (error) { + this.$emit('error', __('Something went wrong when deleting a comment. Please try again')); + Sentry.captureException(error); } }, }, @@ -172,7 +222,7 @@ export default { </script> <template> - <div class="gl-border-t gl-mt-5"> + <div class="gl-border-t gl-mt-5 work-item-notes"> <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap"> <label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label> <activity-filter @@ -181,10 +231,10 @@ export default { :sort-order="sortOrder" :work-item-type="workItemType" @changeSortOrder="changeNotesSortOrder" - @updateSavedSortOrder="updateInitialSortedOrder" + @updateSavedSortOrder="changeNotesSortOrder" /> </div> - <div v-if="showInitialLoader" class="gl-mt-5"> + <div v-if="initialLoading" class="gl-mt-5"> <gl-skeleton-loader v-for="index in $options.loader.repeat" :key="index" @@ -197,22 +247,38 @@ export default { </gl-skeleton-loader> </div> <div v-else class="issuable-discussion gl-mb-5 gl-clearfix!"> - <template v-if="showTimeline"> + <template v-if="!initialLoading"> <ul class="notes main-notes-list timeline gl-clearfix!"> - <template v-for="note in notesArray"> + <work-item-add-note + v-if="formAtTop" + v-bind="workItemCommentFormProps" + @error="$emit('error', $event)" + /> + + <template v-for="discussion in notesArray"> <system-note - v-if="isSystemNote(note)" - :key="note.notes.nodes[0].id" - :note="note.notes.nodes[0]" + v-if="isSystemNote(discussion)" + :key="discussion.notes.nodes[0].id" + :note="discussion.notes.nodes[0]" /> - <work-item-note v-else :key="note.notes.nodes[0].id" :note="note.notes.nodes[0]" /> + <template v-else> + <work-item-discussion + :key="getDiscussionKey(discussion)" + :discussion="discussion.notes.nodes" + :query-variables="queryVariables" + :full-path="fullPath" + :work-item-id="workItemId" + :fetch-by-iid="fetchByIid" + :work-item-type="workItemType" + @deleteNote="showDeleteNoteModal($event, discussion)" + @error="$emit('error', $event)" + /> + </template> </template> - <work-item-comment-form - :query-variables="queryVariables" - :full-path="fullPath" - :work-item-id="workItemId" - :fetch-by-iid="fetchByIid" + <work-item-add-note + v-if="!formAtTop" + v-bind="workItemCommentFormProps" @error="$emit('error', $event)" /> </ul> @@ -231,5 +297,17 @@ export default { </gl-skeleton-loader> </template> </div> + <gl-modal + ref="deleteNoteModal" + modal-id="delete-note-modal" + :title="__('Delete comment?')" + :ok-title="__('Delete comment')" + ok-variant="danger" + size="sm" + @primary="deleteNote" + @canceled="cancelDeletingNote" + > + {{ __('Are you sure you want to delete this comment?') }} + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql b/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql new file mode 100644 index 00000000000..30a5d2388b1 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql @@ -0,0 +1,3 @@ +mutation addHierarchyChild($id: WorkItemID!, $workItem: WorkItem!) { + addHierarchyChild(id: $id, workItem: $workItem) @client +} diff --git a/app/assets/javascripts/work_items/graphql/cache_utils.js b/app/assets/javascripts/work_items/graphql/cache_utils.js new file mode 100644 index 00000000000..16b892b3476 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/cache_utils.js @@ -0,0 +1,62 @@ +import { produce } from 'immer'; +import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; +import { getWorkItemNotesQuery } from '~/work_items/utils'; + +/** + * Updates the cache manually when adding a main comment + * + * @param store + * @param createNoteData + * @param fetchByIid + * @param queryVariables + * @param sortOrder + */ +export const updateCommentState = (store, { data: { createNote } }, fetchByIid, queryVariables) => { + const notesQuery = getWorkItemNotesQuery(fetchByIid); + const variables = { + ...queryVariables, + pageSize: 100, + }; + const sourceData = store.readQuery({ + query: notesQuery, + variables, + }); + + const finalData = produce(sourceData, (draftData) => { + const notesWidget = fetchByIid + ? draftData.workspace.workItems.nodes[0].widgets.find( + (widget) => widget.type === WIDGET_TYPE_NOTES, + ) + : draftData.workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_NOTES); + + // as notes are currently sorted/reversed on the frontend rather than in the query + // we only ever push. + // const arrayPushMethod = sortOrder === ASC ? 'push' : 'unshift'; + const arrayPushMethod = 'push'; + + // manual update of cache with a completely new discussion + if (createNote.note.discussion.notes.nodes.length === 1) { + notesWidget.discussions.nodes[arrayPushMethod]({ + id: createNote.note.discussion.id, + notes: { + nodes: createNote.note.discussion.notes.nodes, + __typename: 'NoteConnection', + }, + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Discussion', + }); + } + + if (fetchByIid) { + draftData.workspace.workItems.nodes[0].widgets[6] = notesWidget; + } else { + draftData.workItem.widgets[6] = notesWidget; + } + }); + + store.writeQuery({ + query: notesQuery, + variables, + data: finalData, + }); +}; diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql deleted file mode 100644 index 6a7afd7bd5b..00000000000 --- a/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation createWorkItemNote($input: CreateNoteInput!) { - createNote(input: $input) { - errors - } -} diff --git a/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql new file mode 100644 index 00000000000..5050aa7cbda --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql @@ -0,0 +1,18 @@ +#import "./work_item_note.fragment.graphql" + +mutation createWorkItemNote($input: CreateNoteInput!) { + createNote(input: $input) { + note { + id + discussion { + id + notes { + nodes { + ...WorkItemNote + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/notes/delete_work_item_notes.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/delete_work_item_notes.mutation.graphql new file mode 100644 index 00000000000..592b5c2a991 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/delete_work_item_notes.mutation.graphql @@ -0,0 +1,7 @@ +mutation deleteWorkItemNote($input: DestroyNoteInput!) { + destroyNote(input: $input) { + note { + id + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql new file mode 100644 index 00000000000..3da8e7677e4 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql @@ -0,0 +1,10 @@ +#import "./work_item_note.fragment.graphql" + +mutation updateWorkItemNote($input: UpdateNoteInput!) { + updateNote(input: $input) { + note { + ...WorkItemNote + } + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql new file mode 100644 index 00000000000..58561e33e53 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql @@ -0,0 +1,25 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "./work_item_note.fragment.graphql" + +fragment WorkItemDiscussionNote on Note { + id + bodyHtml + system + internal + systemNoteIconName + createdAt + author { + ...User + } + userPermissions { + adminNote + } + discussion { + id + notes { + nodes { + ...WorkItemNote + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql index 5215ea10918..52a7a1f8e23 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql @@ -2,15 +2,29 @@ fragment WorkItemNote on Note { id + body bodyHtml system internal systemNoteIconName createdAt + lastEditedAt + lastEditedBy { + ...User + webPath + } + discussion { + id + } author { ...User } userPermissions { adminNote + awardEmoji + readNote + createNote + resolveNote + repositionNote } } diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_created.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_created.subscription.graphql new file mode 100644 index 00000000000..739f2101b5e --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_created.subscription.graphql @@ -0,0 +1,7 @@ +#import "./work_item_discussion_note.fragment.graphql" + +subscription workItemNoteCreated($noteableId: NoteableID) { + workItemNoteCreated(noteableId: $noteableId) { + ...WorkItemDiscussionNote + } +} diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_deleted.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_deleted.subscription.graphql new file mode 100644 index 00000000000..6a59becdb99 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_deleted.subscription.graphql @@ -0,0 +1,7 @@ +subscription workItemNoteDeleted($noteableId: NoteableID) { + workItemNoteDeleted(noteableId: $noteableId) { + id + discussionId + lastDiscussionNote + } +} diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql new file mode 100644 index 00000000000..c68d5f491cf --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql @@ -0,0 +1,7 @@ +#import "./work_item_note.fragment.graphql" + +subscription workItemNoteUpdated($noteableId: NoteableID) { + workItemNoteUpdated(noteableId: $noteableId) { + ...WorkItemNote + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql index 9ea9cecc81a..56dc175109f 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" -#import "~/work_items/graphql/work_item_note.fragment.graphql" +#import "./work_item_note.fragment.graphql" query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) { workItem(id: $id) { diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql index f401aa5595e..6b37c68cb43 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" -#import "~/work_items/graphql/work_item_note.fragment.graphql" +#import "./work_item_note.fragment.graphql" query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { workspace: project(fullPath: $fullPath) { diff --git a/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql b/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql new file mode 100644 index 00000000000..3fece06eefa --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql @@ -0,0 +1,3 @@ +mutation removeHierarchyChild($id: WorkItemID!, $workItem: WorkItem!) { + removeHierarchyChild(id: $id, workItem: $workItem) @client +} 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 3ee263c149d..ada9f737e6e 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -1,4 +1,5 @@ #import "ee_else_ce/work_items/graphql/work_item_widgets.fragment.graphql" +#import "~/graphql_shared/fragments/author.fragment.graphql" fragment WorkItem on WorkItem { id @@ -8,12 +9,16 @@ fragment WorkItem on WorkItem { description confidential createdAt + updatedAt closedAt project { id fullPath archived } + author { + ...Author + } workItemType { id name diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql index b7813ca4dc6..b5d27231bef 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql @@ -3,6 +3,15 @@ #import "~/work_items/graphql/milestone.fragment.graphql" fragment WorkItemMetadataWidgets on WorkItemWidget { + ... on WorkItemWidgetDescription { + type + } + ... on WorkItemWidgetStartAndDueDate { + type + } + ... on WorkItemWidgetNotes { + type + } ... on WorkItemWidgetMilestone { type milestone { @@ -11,6 +20,8 @@ fragment WorkItemMetadataWidgets on WorkItemWidget { } ... on WorkItemWidgetAssignees { type + allowsMultipleAssignees + canInviteMembers assignees { nodes { ...User diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index d2a2d7927d3..bf8eafe3211 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -55,6 +55,7 @@ fragment WorkItemWidgets on WorkItemWidget { children { nodes { id + iid confidential workItemType { id diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 98b59449af7..6aa63aae172 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -1,9 +1,12 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { parseBoolean } from '~/lib/utils/common_utils'; import { apolloProvider } from '~/graphql_shared/issuable_client'; import App from './components/app.vue'; import { createRouter } from './router'; +Vue.use(VueApollo); + export const initWorkItemsRoot = () => { const el = document.querySelector('#js-work-items'); const { 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 d04d4942253..4f8c720eb1f 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -1,6 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { visitUrl } from '~/lib/utils/url_utility'; import ZenMode from '~/zen_mode'; @@ -31,7 +31,7 @@ export default { }, computed: { gid() { - return convertToGraphQLId(TYPE_WORK_ITEM, this.id); + return convertToGraphQLId(TYPENAME_WORK_ITEM, this.id); }, }, mounted() { diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index e58fd19ea31..f2af87d476c 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -1,7 +1,8 @@ +import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants'; import workItemQuery from './graphql/work_item.query.graphql'; import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql'; -import workItemNotesIdQuery from './graphql/work_item_notes.query.graphql'; -import workItemNotesByIidQuery from './graphql/work_item_notes_by_iid.query.graphql'; +import workItemNotesIdQuery from './graphql/notes/work_item_notes.query.graphql'; +import workItemNotesByIidQuery from './graphql/notes/work_item_notes_by_iid.query.graphql'; export function getWorkItemQuery(isFetchedByIid) { return isFetchedByIid ? workItemByIidQuery : workItemQuery; @@ -10,3 +11,23 @@ export function getWorkItemQuery(isFetchedByIid) { export function getWorkItemNotesQuery(isFetchedByIid) { return isFetchedByIid ? workItemNotesByIidQuery : workItemNotesIdQuery; } + +export const findHierarchyWidgetChildren = (workItem) => + workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY).children.nodes; + +const autocompleteSourcesPath = (autocompleteType, fullPath, workItemIid) => { + return `${ + gon.relative_url_root || '' + }/${fullPath}/-/autocomplete_sources/${autocompleteType}?type=WorkItem&type_id=${workItemIid}`; +}; + +export const autocompleteDataSources = (fullPath, iid) => ({ + labels: autocompleteSourcesPath('labels', fullPath, iid), + members: autocompleteSourcesPath('members', fullPath, iid), + commands: autocompleteSourcesPath('commands', fullPath, iid), +}); + +export const markdownPreviewPath = (fullPath, iid) => + `${ + gon.relative_url_root || '' + }/${fullPath}/preview_markdown?target_type=WorkItem&target_id=${iid}`; |