diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-20 12:40:42 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-20 12:40:42 +0300 |
commit | ee664acb356f8123f4f6b00b73c1e1cf0866c7fb (patch) | |
tree | f8479f94a28f66654c6a4f6fb99bad6b4e86a40e /app/assets/javascripts/vue_shared/components | |
parent | 62f7d5c5b69180e82ae8196b7b429eeffc8e7b4f (diff) |
Add latest changes from gitlab-org/gitlab@15-5-stable-eev15.5.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_shared/components')
71 files changed, 1051 insertions, 836 deletions
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 84bd6bca601..c93057c491c 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -1,6 +1,5 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import CiIcon from './ci_icon.vue'; /** * Renders CI Badge link with CI icon and status text based on @@ -27,6 +26,7 @@ import CiIcon from './ci_icon.vue'; export default { components: { + GlLink, CiIcon, }, directives: { @@ -61,29 +61,21 @@ export default { return className ? `ci-status ci-${className}` : 'ci-status'; }, }, - methods: { - navigateToPipeline() { - visitUrl(this.detailsPath); - - // event used for tracking - this.$emit('ciStatusBadgeClick'); - }, - }, }; </script> <template> - <a + <gl-link v-gl-tooltip :class="cssClass" - class="gl-cursor-pointer" :title="title" data-qa-selector="status_badge_link" - @click="navigateToPipeline" + :href="detailsPath" + @click="$emit('ciStatusBadgeClick')" > <ci-icon :status="status" :css-classes="iconClasses" /> <template v-if="showText"> {{ status.text }} </template> - </a> + </gl-link> </template> diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue index a88a4ca5cb8..75386a3cd01 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue @@ -1,6 +1,6 @@ <script> import { isString } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { s__ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { DEFAULT_COLOR, COLOR_WIDGET_COLOR, DROPDOWN_VARIANT, ISSUABLE_COLORS } from './constants'; @@ -97,7 +97,7 @@ export default { return DEFAULT_COLOR; }, error() { - createFlash({ + createAlert({ message: this.$options.i18n.fetchingError, captureError: true, }); @@ -161,7 +161,7 @@ export default { }); }) .catch((error) => - createFlash({ + createAlert({ message: this.$options.i18n.updatingError, captureError: true, error, diff --git a/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue b/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue index 298c7bc50cc..31c98d1e3a7 100644 --- a/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue +++ b/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue @@ -33,7 +33,7 @@ export default { :title="confidentialTooltip" icon="eye-slash" variant="warning" - class="gl-display-inline gl-mr-2" + class="gl-display-inline gl-mr-3" >{{ __('Confidential') }}</gl-badge > </template> 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 ffe09634a3b..4873996d357 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 @@ -56,3 +56,9 @@ export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); export const TOKEN_TITLE_ORGANIZATION = s__('Crm|Organization'); export const TOKEN_TITLE_RELEASE = __('Release'); export const TOKEN_TITLE_TYPE = __('Type'); + +// 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 +// from the CE component, we need to keep this in the CE code. +// https://gitlab.com/gitlab-org/gitlab/-/issues/377838 +export const TOKEN_TYPE_HEALTH = 'health_status'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index e311df6e66f..8821084ef35 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -12,7 +12,7 @@ import { import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { SortDirection } from './constants'; @@ -197,7 +197,7 @@ export default { .catch((error) => { if (error.name === 'RecentSearchesServiceError') return undefined; - createFlash({ + createAlert({ message: __('An error occurred while parsing recent searches'), }); @@ -346,6 +346,11 @@ export default { :suggestions-list-class="suggestionsListClass" :search-button-attributes="searchButtonAttributes" :search-input-attributes="searchInputAttributes" + :recent-searches-header="__('Recent searches')" + :clear-button-title="__('Clear')" + :close-button-title="__('Close')" + :clear-recent-searches-text="__('Clear recent searches')" + :no-recent-searches-text="__(`You don't have any recent searches`)" class="flex-grow-1" @history-item-selected="handleHistoryItemSelected" @clear="onClear" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js index 7c4e372dda1..8a6053b7001 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import * as types from './mutation_types'; @@ -24,7 +24,7 @@ export function fetchBranches({ commit, state }, search = '') { .catch(({ response }) => { const { status } = response; commit(types.RECEIVE_BRANCHES_ERROR, status); - createFlash({ + createAlert({ message: __('Failed to load branches. Please try again.'), }); }); @@ -43,7 +43,7 @@ export const fetchMilestones = ({ commit, state }, searchTitle = '') => { .catch(({ response }) => { const { status } = response; commit(types.RECEIVE_MILESTONES_ERROR, status); - createFlash({ + createAlert({ message: __('Failed to load milestones. Please try again.'), }); }); @@ -61,7 +61,7 @@ export const fetchLabels = ({ commit, state }, search = '') => { .catch(({ response }) => { const { status } = response; commit(types.RECEIVE_LABELS_ERROR, status); - createFlash({ + createAlert({ message: __('Failed to load labels. Please try again.'), }); }); @@ -86,7 +86,7 @@ function fetchUser(options = {}) { .catch(({ response }) => { const { status } = response; commit(`RECEIVE_${action}_ERROR`, status); - createFlash({ + createAlert({ message: errorMessage, }); }); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index 848c49c48c7..7c184a3c391 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -1,7 +1,7 @@ <script> import { GlAvatar, GlFilteredSearchSuggestion } from '@gitlab/ui'; import { compact } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { DEFAULT_NONE_ANY } from '../constants'; @@ -65,7 +65,7 @@ export default { this.authors = Array.isArray(res) ? compact(res) : compact(res.data); }) .catch(() => - createFlash({ + createAlert({ message: __('There was a problem fetching users.'), }), ) diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue index aa5161ca93c..741395b3193 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue @@ -1,6 +1,6 @@ <script> import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -46,7 +46,7 @@ export default { this.branches = data; }) .catch(() => { - createFlash({ message: __('There was a problem fetching branches.') }); + createAlert({ message: __('There was a problem fetching branches.') }); }) .finally(() => { this.loading = false; 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 adfe0559b62..d34cfb922a9 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 @@ -3,7 +3,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { ITEM_TYPE } from '~/groups/constants'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; import searchCrmContactsQuery from '../queries/search_crm_contacts.query.graphql'; @@ -81,7 +81,7 @@ export default { : data[this.namespace]?.contacts.nodes; }) .catch(() => - createFlash({ + createAlert({ message: __('There was a problem fetching CRM contacts.'), }), ) 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 e6ab944449e..c7c9350ee93 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 @@ -3,7 +3,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { ITEM_TYPE } from '~/groups/constants'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; import searchCrmOrganizationsQuery from '../queries/search_crm_organizations.query.graphql'; @@ -78,7 +78,7 @@ export default { : data[this.namespace]?.organizations.nodes; }) .catch(() => - createFlash({ + createAlert({ message: __('There was a problem fetching CRM organizations.'), }), ) diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue index 210d814d22a..929823f7308 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -1,6 +1,6 @@ <script> import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { DEFAULT_NONE_ANY } from '../constants'; @@ -48,7 +48,7 @@ export default { this.emojis = Array.isArray(response) ? response : response.data; }) .catch(() => { - createFlash({ message: __('There was a problem fetching emojis.') }); + createAlert({ message: __('There was a problem fetching emojis.') }); }) .finally(() => { this.loading = false; 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 178c57a5666..bce0c11aafd 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 @@ -1,7 +1,7 @@ <script> import { GlToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; @@ -81,7 +81,7 @@ export default { this.labels = Array.isArray(res) ? res : res.data; }) .catch(() => - createFlash({ + createAlert({ message: __('There was a problem fetching labels.'), }), ) diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 69265d0fdc9..b9ee4d51db1 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -1,6 +1,6 @@ <script> import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { sortMilestonesByDueDate } from '~/milestones/utils'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -65,7 +65,7 @@ export default { } }) .catch(() => { - createFlash({ message: __('There was a problem fetching milestones.') }); + createAlert({ message: __('There was a problem fetching milestones.') }); }) .finally(() => { this.loading = false; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue index 9e68c92af5d..59701b4959e 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue @@ -1,6 +1,6 @@ <script> import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { DEFAULT_NONE_ANY } from '../constants'; @@ -47,7 +47,7 @@ export default { this.releases = response; }) .catch(() => { - createFlash({ message: __('There was a problem fetching releases.') }); + createAlert({ message: __('There was a problem fetching releases.') }); }) .finally(() => { this.loading = false; diff --git a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue index 72148a0aa7c..c2be5e4f7a1 100644 --- a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue +++ b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue @@ -1,8 +1,10 @@ <script> import { GlBadge } from '@gitlab/ui'; import { s__ } from '~/locale'; +import Tracking from '~/tracking'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; +import { helpPagePath } from '~/helpers/help_page_helper'; const STATUS_TYPES = { SUCCESS: 'success', @@ -10,11 +12,14 @@ const STATUS_TYPES = { DANGER: 'danger', }; +const UPGRADE_DOCS_URL = helpPagePath('update/index'); + export default { name: 'GitlabVersionCheck', components: { GlBadge, }, + mixins: [Tracking.mixin()], props: { size: { type: String, @@ -50,6 +55,10 @@ export default { .then((res) => { if (res.data) { this.status = res.data.severity; + + this.track('rendered_version_badge', { + label: this.title, + }); } }) .catch(() => { @@ -57,12 +66,24 @@ export default { this.status = null; }); }, + onClick() { + this.track('click_version_badge', { label: this.title }); + }, }, + UPGRADE_DOCS_URL, }; </script> <template> - <gl-badge v-if="status" class="version-check-badge" :variant="status" :size="size">{{ - title - }}</gl-badge> + <!-- TODO: remove the span element once bootstrap-vue is updated to version 2.21.1 --> + <!-- TODO: https://github.com/bootstrap-vue/bootstrap-vue/issues/6219 --> + <span v-if="status" data-testid="badge-click-wrapper" @click="onClick"> + <gl-badge + :href="$options.UPGRADE_DOCS_URL" + class="version-check-badge" + :variant="status" + :size="size" + >{{ title }}</gl-badge + > + </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/group_select/utils.js b/app/assets/javascripts/vue_shared/components/group_select/utils.js new file mode 100644 index 00000000000..0a4622269f4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/group_select/utils.js @@ -0,0 +1,15 @@ +import Api from '~/api'; + +export const groupsPath = (groupsFilter, parentGroupID) => { + if (groupsFilter !== undefined && parentGroupID === undefined) { + throw new Error('Cannot use groupsFilter without a parentGroupID'); + } + switch (groupsFilter) { + case 'descendant_groups': + return Api.descendantGroupsPath.replace(':id', parentGroupID); + case 'subgroups': + return Api.subgroupsPath.replace(':id', parentGroupID); + default: + return Api.groupsPath; + } +}; 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 new file mode 100644 index 00000000000..d80c1ff8b0c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js @@ -0,0 +1,12 @@ +import { issuableTypes } from '~/boards/constants'; +import blockingIssuesQuery from './graphql/blocking_issues.query.graphql'; +import blockingEpicsQuery from './graphql/blocking_epics.query.graphql'; + +export const blockingIssuablesQueries = { + [issuableTypes.issue]: { + query: blockingIssuesQuery, + }, + [issuableTypes.epic]: { + query: blockingEpicsQuery, + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/graphql/blocking_epics.query.graphql b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/graphql/blocking_epics.query.graphql new file mode 100644 index 00000000000..4b9a9243052 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/graphql/blocking_epics.query.graphql @@ -0,0 +1,17 @@ +query BlockingEpics($fullPath: ID!, $iid: ID) { + group(fullPath: $fullPath) { + id + issuable: epic(iid: $iid) { + id + blockingIssuables: blockedByEpics { + nodes { + id + iid + title + reference(full: true) + webUrl + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/graphql/blocking_issues.query.graphql b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/graphql/blocking_issues.query.graphql new file mode 100644 index 00000000000..279c2202740 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/graphql/blocking_issues.query.graphql @@ -0,0 +1,14 @@ +query BlockingIssues($id: IssueID!) { + issuable: issue(id: $id) { + id + blockingIssuables: blockedByIssues { + nodes { + id + iid + title + reference(full: true) + webUrl + } + } + } +} 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 new file mode 100644 index 00000000000..253aca8837d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue @@ -0,0 +1,214 @@ +<script> +import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui'; +import { issuableTypes } from '~/boards/constants'; +import { TYPE_ISSUE, TYPE_EPIC } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { truncate } from '~/lib/utils/text_utility'; +import { __, n__, s__, sprintf } from '~/locale'; +import { blockingIssuablesQueries } from './constants'; + +export default { + i18n: { + issuableType: { + [issuableTypes.issue]: __('issue'), + [issuableTypes.epic]: __('epic'), + }, + }, + graphQLIdType: { + [issuableTypes.issue]: TYPE_ISSUE, + [issuableTypes.epic]: TYPE_EPIC, + }, + referenceFormatter: { + [issuableTypes.issue]: (r) => r.split('/')[1], + }, + defaultDisplayLimit: 3, + textTruncateWidth: 80, + components: { + GlIcon, + GlPopover, + GlLink, + GlLoadingIcon, + }, + props: { + item: { + type: Object, + required: true, + }, + uniqueId: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: true, + validator(value) { + return [issuableTypes.issue, issuableTypes.epic].includes(value); + }, + }, + }, + apollo: { + blockingIssuables: { + skip() { + return this.skip; + }, + query() { + return blockingIssuablesQueries[this.issuableType].query; + }, + variables() { + if (this.isEpic) { + return { + fullPath: this.item.group.fullPath, + iid: Number(this.item.iid), + }; + } + return { + id: convertToGraphQLId(this.$options.graphQLIdType[this.issuableType], this.item.id), + }; + }, + update(data) { + this.skip = true; + const issuable = this.isEpic ? data?.group?.issuable : data?.issuable; + + return issuable?.blockingIssuables?.nodes || []; + }, + error(error) { + const message = sprintf(s__('Boards|Failed to fetch blocking %{issuableType}s'), { + issuableType: this.issuableTypeText, + }); + this.$emit('blocking-issuables-error', { error, message }); + }, + }, + }, + data() { + return { + skip: true, + blockingIssuables: [], + }; + }, + computed: { + isEpic() { + return this.issuableType === issuableTypes.epic; + }, + displayedIssuables() { + const { defaultDisplayLimit, referenceFormatter } = this.$options; + return this.blockingIssuables.slice(0, defaultDisplayLimit).map((i) => { + return { + ...i, + title: truncate(i.title, this.$options.textTruncateWidth), + reference: this.isEpic ? i.reference : referenceFormatter[this.issuableType](i.reference), + }; + }); + }, + loading() { + return this.$apollo.queries.blockingIssuables.loading; + }, + issuableTypeText() { + return this.$options.i18n.issuableType[this.issuableType]; + }, + blockedLabel() { + return sprintf( + n__( + 'Boards|Blocked by %{blockedByCount} %{issuableType}', + 'Boards|Blocked by %{blockedByCount} %{issuableType}s', + this.item.blockedByCount, + ), + { + blockedByCount: this.item.blockedByCount, + issuableType: this.issuableTypeText, + }, + ); + }, + blockIcon() { + return this.issuableType === issuableTypes.issue ? 'issue-block' : 'entity-blocked'; + }, + glIconId() { + return `blocked-icon-${this.uniqueId}`; + }, + hasMoreIssuables() { + return this.item.blockedByCount > this.$options.defaultDisplayLimit; + }, + displayedIssuablesCount() { + return this.hasMoreIssuables + ? this.item.blockedByCount - this.$options.defaultDisplayLimit + : this.item.blockedByCount; + }, + moreIssuablesText() { + return sprintf( + n__( + 'Boards|+ %{displayedIssuablesCount} more %{issuableType}', + 'Boards|+ %{displayedIssuablesCount} more %{issuableType}s', + this.displayedIssuablesCount, + ), + { + displayedIssuablesCount: this.displayedIssuablesCount, + issuableType: this.issuableTypeText, + }, + ); + }, + viewAllIssuablesText() { + return sprintf(s__('Boards|View all blocking %{issuableType}s'), { + issuableType: this.issuableTypeText, + }); + }, + loadingMessage() { + return sprintf(s__('Boards|Retrieving blocking %{issuableType}s'), { + issuableType: this.issuableTypeText, + }); + }, + }, + methods: { + handleMouseEnter() { + this.skip = false; + }, + }, +}; +</script> +<template> + <div class="gl-display-inline"> + <gl-icon + :id="glIconId" + ref="icon" + :name="blockIcon" + class="issuable-blocked-icon gl-mr-2 gl-cursor-pointer gl-text-red-500" + data-testid="issuable-blocked-icon" + @mouseenter="handleMouseEnter" + /> + <gl-popover :target="glIconId" placement="top"> + <template #title + ><span data-testid="popover-title">{{ blockedLabel }}</span></template + > + <template v-if="loading"> + <gl-loading-icon size="sm" /> + <p class="gl-mt-4 gl-mb-0 gl-font-small">{{ loadingMessage }}</p> + </template> + <template v-else> + <ul class="gl-list-style-none gl-p-0 gl-mb-0"> + <li v-for="(issuable, index) in displayedIssuables" :key="issuable.id"> + <gl-link :href="issuable.webUrl" class="gl-text-blue-500! gl-font-sm">{{ + issuable.reference + }}</gl-link> + <p + class="gl-display-block!" + :class="{ + 'gl-mb-3': index < displayedIssuables.length - 1, + 'gl-mb-0': index === displayedIssuables.length - 1, + }" + data-testid="issuable-title" + > + {{ issuable.title }} + </p> + </li> + </ul> + <div v-if="hasMoreIssuables" class="gl-mt-4"> + <p class="gl-mb-3" data-testid="hidden-blocking-count">{{ moreIssuablesText }}</p> + <gl-link + data-testid="view-all-issues" + :href="`${item.webUrl}#related-issues`" + class="gl-text-blue-500! gl-font-sm" + >{{ viewAllIssuablesText }}</gl-link + > + </div> + </template> + </gl-popover> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue index 926034efd10..caec49c557a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -51,6 +51,7 @@ export default { <gl-dropdown :text="dropdownText" :disabled="disabled" + size="small" boundary="window" right lazy diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 32b3a0e22c2..657e4498b53 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -3,7 +3,7 @@ import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; import { debounce, unescape } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import GLForm from '~/gl_form'; import axios from '~/lib/utils/axios_utils'; import { stripHtml } from '~/lib/utils/text_utility'; @@ -272,7 +272,7 @@ export default { this.fetchMarkdown() .then((data) => this.renderMarkdown(data)) .catch(() => - createFlash({ + createAlert({ message: __('Error loading markdown preview'), }), ); @@ -315,7 +315,7 @@ export default { this.$nextTick() .then(() => $(this.$refs['markdown-preview']).renderGFM()) .catch(() => - createFlash({ + createAlert({ message: __('Error rendering Markdown preview'), }), ); diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 458dfe0ed23..89fffdedbfd 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -7,6 +7,8 @@ import { ITALIC_TEXT, STRIKETHROUGH_TEXT, LINK_TEXT, + INDENT_LINE, + OUTDENT_LINE, } from '~/behaviors/shortcuts/keybindings'; import { getSelectedFragment } from '~/lib/utils/common_utils'; import { s__, __ } from '~/locale'; @@ -68,12 +70,15 @@ export default { }, computed: { mdTable() { + const header = s__('MarkdownEditor|header'); + const divider = '-'.repeat(header.length); + const cell = ' '.repeat(header.length); + return [ - // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 - '| header | header |', // eslint-disable-line @gitlab/require-i18n-strings - '| ------ | ------ |', - '| cell | cell |', // eslint-disable-line @gitlab/require-i18n-strings - '| cell | cell |', // eslint-disable-line @gitlab/require-i18n-strings + `| ${header} | ${header} |`, + `| ${divider} | ${divider} |`, + `| ${cell} | ${cell} |`, + `| ${cell} | ${cell} |`, ].join('\n'); }, mdSuggestion() { @@ -82,7 +87,8 @@ export default { ); }, mdCollapsibleSection() { - return ['<details><summary>Click to expand</summary>', `{text}`, '</details>'].join('\n'); + const expandText = s__('MarkdownEditor|Click to expand'); + return [`<details><summary>${expandText}</summary>`, `{text}`, '</details>'].join('\n'); }, isMac() { // Accessing properties using ?. to allow tests to use @@ -170,6 +176,8 @@ export default { italic: keysFor(ITALIC_TEXT), strikethrough: keysFor(STRIKETHROUGH_TEXT), link: keysFor(LINK_TEXT), + indent: keysFor(INDENT_LINE), + outdent: keysFor(OUTDENT_LINE), }, i18n: { writeTabTitle: __('Write'), @@ -235,6 +243,7 @@ export default { variant="confirm" category="primary" size="small" + data-qa-selector="dismiss_suggestion_popover_button" @click="handleSuggestDismissed" > {{ __('Got it') }} @@ -318,6 +327,32 @@ export default { icon="list-task" /> <toolbar-button + v-if="!restrictedToolBarItems.includes('indent')" + class="gl-display-none" + :button-title=" + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf(s__('MarkdownEditor|Indent line (%{modifierKey}])'), { + modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */, + }) + " + :shortcuts="$options.shortcuts.indent" + command="indentLines" + icon="list-indent" + /> + <toolbar-button + v-if="!restrictedToolBarItems.includes('outdent')" + class="gl-display-none" + :button-title=" + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf(s__('MarkdownEditor|Outdent line (%{modifierKey}[)'), { + modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */, + }) + " + :shortcuts="$options.shortcuts.outdent" + command="outdentLines" + icon="list-outdent" + /> + <toolbar-button v-if="!restrictedToolBarItems.includes('collapsible-section')" :tag="mdCollapsibleSection" :prepend="true" diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue new file mode 100644 index 00000000000..b38772d5aa5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -0,0 +1,216 @@ +<script> +import { GlSegmentedControl } from '@gitlab/ui'; +import { __ } from '~/locale'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import axios from '~/lib/utils/axios_utils'; +import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR } from '../../constants'; +import MarkdownField from './field.vue'; + +export default { + components: { + MarkdownField, + LocalStorageSync, + GlSegmentedControl, + ContentEditor: () => + import( + /* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue' + ), + }, + props: { + value: { + type: String, + required: true, + }, + renderMarkdownPath: { + type: String, + required: true, + }, + markdownDocsPath: { + type: String, + required: true, + }, + quickActionsDocsPath: { + type: String, + required: false, + default: '', + }, + uploadsPath: { + type: String, + required: false, + default: () => window.uploads_path, + }, + enableContentEditor: { + type: Boolean, + required: false, + default: true, + }, + formFieldId: { + type: String, + required: true, + }, + formFieldName: { + type: String, + required: true, + }, + enablePreview: { + type: Boolean, + required: false, + default: true, + }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, + formFieldPlaceholder: { + type: String, + required: false, + default: '', + }, + formFieldAriaLabel: { + type: String, + required: false, + default: '', + }, + initOnAutofocus: { + type: Boolean, + required: false, + default: false, + }, + supportsQuickActions: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + editingMode: EDITING_MODE_MARKDOWN_FIELD, + switchEditingControlEnabled: true, + autofocus: this.initOnAutofocus, + }; + }, + computed: { + isContentEditorActive() { + return this.enableContentEditor && this.editingMode === EDITING_MODE_CONTENT_EDITOR; + }, + contentEditorAutofocus() { + // Match textarea focus behavior + return this.autofocus ? 'end' : false; + }, + }, + mounted() { + this.autofocusTextarea(this.editingMode); + }, + methods: { + updateMarkdownFromContentEditor({ markdown }) { + this.$emit('input', markdown); + }, + updateMarkdownFromMarkdownField({ target }) { + this.$emit('input', target.value); + }, + enableSwitchEditingControl() { + this.switchEditingControlEnabled = true; + }, + disableSwitchEditingControl() { + this.switchEditingControlEnabled = false; + }, + renderMarkdown(markdown) { + return axios.post(this.renderMarkdownPath, { text: markdown }).then(({ data }) => data.body); + }, + onEditingModeChange(editingMode) { + this.notifyEditingModeChange(editingMode); + this.enableAutofocus(editingMode); + }, + onEditingModeRestored(editingMode) { + this.notifyEditingModeChange(editingMode); + }, + notifyEditingModeChange(editingMode) { + this.$emit(editingMode); + }, + enableAutofocus(editingMode) { + this.autofocus = true; + this.autofocusTextarea(editingMode); + }, + autofocusTextarea(editingMode) { + if (this.autofocus && editingMode === EDITING_MODE_MARKDOWN_FIELD) { + this.$refs.textarea.focus(); + } + }, + }, + switchEditingControlOptions: [ + { text: __('Source'), value: EDITING_MODE_MARKDOWN_FIELD }, + { text: __('Rich text'), value: EDITING_MODE_CONTENT_EDITOR }, + ], +}; +</script> +<template> + <div> + <div class="gl-display-flex gl-justify-content-start gl-mb-3"> + <gl-segmented-control + v-model="editingMode" + data-testid="toggle-editing-mode-button" + data-qa-selector="editing_mode_button" + class="gl-display-flex" + :options="$options.switchEditingControlOptions" + :disabled="!enableContentEditor || !switchEditingControlEnabled" + @change="onEditingModeChange" + /> + </div> + <local-storage-sync + v-model="editingMode" + storage-key="gl-wiki-content-editor-enabled" + @input="onEditingModeRestored" + /> + <markdown-field + v-if="!isContentEditorActive" + :markdown-preview-path="renderMarkdownPath" + can-attach-file + :enable-autocomplete="enableAutocomplete" + :textarea-value="value" + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + :uploads-path="uploadsPath" + :enable-preview="enablePreview" + class="bordered-box" + > + <template #textarea> + <textarea + :id="formFieldId" + 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)" + > + </textarea> + </template> + </markdown-field> + <div v-else> + <content-editor + :render-markdown="renderMarkdown" + :uploads-path="uploadsPath" + :markdown="value" + :autofocus="contentEditorAutofocus" + @change="updateMarkdownFromContentEditor" + @loading="disableSwitchEditingControl" + @loadingSuccess="enableSwitchEditingControl" + @loadingError="enableSwitchEditingControl" + @keydown="$emit('keydown', $event)" + /> + <input + :id="formFieldId" + :value="value" + :name="formFieldName" + data-qa-selector="markdown_editor_form_field" + type="hidden" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 7646a8718d6..855c7a449c4 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -139,7 +139,7 @@ export default { </script> <template> - <div class="md-suggestion-header border-bottom-0 gl-mt-3"> + <div class="md-suggestion-header border-bottom-0 gl-px-4 gl-py-3"> <div class="js-suggestion-diff-header gl-font-weight-bold"> {{ __('Suggested change') }} <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')" class="js-help-btn"> @@ -162,6 +162,7 @@ export default { <gl-button class="btn-inverted js-remove-from-batch-btn btn-grouped" :disabled="isApplying" + size="small" @click="removeSuggestionFromBatch" > {{ __('Remove from batch') }} @@ -172,6 +173,7 @@ export default { class="btn-inverted js-add-to-batch-btn btn-grouped" data-qa-selector="add_suggestion_batch_button" :disabled="isDisableButton" + size="small" @click="addSuggestionToBatch" > {{ __('Add suggestion to batch') }} diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 9b81444fc04..30d72332c90 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -1,7 +1,7 @@ <script> import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import Vue from 'vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import SuggestionDiff from './suggestion_diff.vue'; @@ -91,7 +91,7 @@ export default { const suggestionElements = container.querySelectorAll('.js-render-suggestion'); if (this.lineType === 'old') { - createFlash({ + createAlert({ message: __('Unable to apply suggestions to a deleted line.'), parent: this.$el, }); diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index 49217e38a1b..5ca21522d33 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -47,6 +47,11 @@ export default { required: false, default: 0, }, + command: { + type: String, + required: false, + default: '', + }, /** * A string (or an array of strings) of @@ -81,6 +86,7 @@ export default { :data-md-tag-content="tagContent" :data-md-prepend="prepend" :data-md-shortcuts="shortcutsString" + :data-md-command="command" :title="buttonTitle" :aria-label="buttonTitle" :icon="icon" diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js index 832fb891838..1c4e8d332a9 100644 --- a/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { s__ } from '~/locale'; import * as types from './mutation_types'; @@ -11,7 +11,7 @@ export const fetchImagesFactory = (service) => async ({ state, commit }) => { commit(types.RECEIVE_METRIC_IMAGES_SUCCESS, response); } catch (error) { commit(types.RECEIVE_METRIC_IMAGES_ERROR); - createFlash({ message: s__('MetricImages|There was an issue loading metric images.') }); + createAlert({ message: s__('MetricImages|There was an issue loading metric images.') }); } }; @@ -34,7 +34,7 @@ export const uploadImageFactory = (service) => async ( commit(types.RECEIVE_METRIC_UPLOAD_SUCCESS, response); } catch (error) { commit(types.RECEIVE_METRIC_UPLOAD_ERROR); - createFlash({ message: s__('MetricImages|There was an issue uploading your image.') }); + createAlert({ message: s__('MetricImages|There was an issue uploading your image.') }); } }; @@ -57,7 +57,7 @@ export const updateImageFactory = (service) => async ( commit(types.RECEIVE_METRIC_UPDATE_SUCCESS, response); } catch (error) { commit(types.RECEIVE_METRIC_UPLOAD_ERROR); - createFlash({ message: s__('MetricImages|There was an issue updating your image.') }); + createAlert({ message: s__('MetricImages|There was an issue updating your image.') }); } }; @@ -68,7 +68,7 @@ export const deleteImageFactory = (service) => async ({ state, commit }, imageId await service.deleteMetricImage({ imageId, id: projectId, modelIid }); commit(types.RECEIVE_METRIC_DELETE_SUCCESS, imageId); } catch (error) { - createFlash({ message: s__('MetricImages|There was an issue deleting the image.') }); + createAlert({ message: s__('MetricImages|There was an issue deleting the image.') }); } }; diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue index d4f50e347cb..41c92fdba4f 100644 --- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue @@ -61,6 +61,11 @@ export default { required: false, default: 'primary', }, + size: { + type: String, + required: false, + default: 'medium', + }, }, computed: { modalDomId() { @@ -103,6 +108,9 @@ export default { :title="title" :aria-label="title" :category="category" + :size="size" icon="copy-to-clipboard" - /> + > + <slot></slot> + </gl-button> </template> diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select_deprecated.vue index e9f278a5db5..ba9edc7620a 100644 --- a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue +++ b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select_deprecated.vue @@ -27,7 +27,7 @@ const filterByName = (data, searchTerm = '') => { }; export default { - name: 'NamespaceSelect', + name: 'NamespaceSelectDeprecated', components: { GlDropdown, GlDropdownDivider, @@ -78,7 +78,7 @@ export default { required: false, default: false, }, - isLoadingMoreGroups: { + isLoading: { type: Boolean, required: false, default: false, @@ -152,7 +152,12 @@ export default { }; </script> <template> - <gl-dropdown :text="selectedNamespaceText" :block="fullWidth" data-qa-selector="namespaces_list"> + <gl-dropdown + :text="selectedNamespaceText" + :block="fullWidth" + data-qa-selector="namespaces_list" + @show="$emit('show')" + > <template #header> <gl-search-box-by-type v-model.trim="searchTerm" @@ -201,8 +206,7 @@ export default { >{{ item.humanName }}</gl-dropdown-item > </div> - <gl-intersection-observer v-if="hasNextPageOfGroups" @appear="$emit('load-more-groups')"> - <gl-loading-icon v-if="isLoadingMoreGroups" class="gl-mb-3" size="sm" /> - </gl-intersection-observer> + <gl-loading-icon v-if="isLoading" class="gl-mb-3" size="sm" /> + <gl-intersection-observer v-if="hasNextPageOfGroups" @appear="$emit('load-more-groups')" /> </gl-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index 0cb4a5bc39f..cf34a60c363 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -50,29 +50,19 @@ export default { renderedNote() { return renderMarkdown(this.note.body); }, - avatarSize() { - if (this.line && !this.isOverviewTab) { - return 24; - } - - return { - default: 24, - md: 32, - }; - }, }, }; </script> <template> - <timeline-entry-item class="note note-wrapper being-posted fade-in-half"> - <div class="timeline-icon"> - <gl-avatar-link class="gl-mr-3" :href="getUserData.path"> + <timeline-entry-item class="note note-wrapper note-comment being-posted fade-in-half"> + <div class="timeline-avatar gl-float-left"> + <gl-avatar-link :href="getUserData.path"> <gl-avatar :src="getUserData.avatar_url" :entity-name="getUserData.username" :alt="getUserData.name" - :size="avatarSize" + :size="32" /> </gl-avatar-link> </div> @@ -85,8 +75,10 @@ export default { </a> </div> </div> - <div class="note-body"> - <div v-safe-html="renderedNote" class="note-text md"></div> + <div class="timeline-discussion-body"> + <div class="note-body"> + <div v-safe-html="renderedNote" class="note-text md"></div> + </div> </div> </div> </timeline-entry-item> diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue index 2206ae98c73..e091fe74717 100644 --- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -16,7 +16,7 @@ export default { <div class="timeline-icon"></div> <div class="timeline-content"> <div class="note-header"></div> - <div class="note-body"><gl-skeleton-loader /></div> + <div class="note-body gl-mt-4"><gl-skeleton-loader /></div> </div> </timeline-entry-item> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 7e99f1b01b2..1ae5045b34f 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -129,7 +129,12 @@ export default { <div v-safe-html:[$options.safeHtmlConfig]="iconHtml" class="timeline-icon"></div> <div class="timeline-content"> <div class="note-header"> - <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> + <note-header + :author="note.author" + :created-at="note.created_at" + :note-id="note.id" + :is-system-note="true" + > <span ref="gfm-content" v-safe-html="actionTextHtml"></span> <template v-if="canSeeDescriptionVersion || note.outdated_line_change_path" @@ -141,7 +146,7 @@ export default { variant="link" :icon="descriptionVersionToggleIcon" data-testid="compare-btn" - class="gl-vertical-align-text-bottom" + class="gl-vertical-align-text-bottom gl-font-sm!" @click="toggleDescriptionVersion" >{{ __('Compare with previous version') }}</gl-button > @@ -150,7 +155,7 @@ export default { :icon="showLines ? 'chevron-up' : 'chevron-down'" variant="link" data-testid="outdated-lines-change-btn" - class="gl-vertical-align-text-bottom" + class="gl-vertical-align-text-bottom gl-font-sm!" @click="toggleDiff" > {{ __('Compare changes') }} @@ -190,7 +195,7 @@ export default { </div> <div v-if="lines.length && showLines" - class="diff-content gl-border-solid gl-border-1 gl-border-gray-200 gl-mt-4 gl-rounded-small gl-overflow-hidden" + class="diff-content outdated-lines-wrapper gl-border-solid gl-border-1 gl-border-gray-200 gl-mt-4 gl-rounded-small gl-overflow-hidden" > <table :class="$options.userColorSchemeClass" diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js index b7768cfa5b9..df1188d365b 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js @@ -4,7 +4,7 @@ export const tdClass = 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap'; export const thClass = 'gl-hover-bg-blue-50'; export const bodyTrClass = - 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-b-solid gl-hover-border-blue-200'; + 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-gray-50 gl-hover-border-b-solid'; export const defaultPageSize = 20; diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue index 6867b5a75e3..a5027d2ca5c 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue @@ -275,7 +275,7 @@ export default { <template> <div class="incident-management-list"> <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="$emit('error-alert-dismissed')"> - <p v-safe-html="serverErrorMessage || i18n.errorMsg"></p> + <span v-safe-html="serverErrorMessage || i18n.errorMsg"></span> </gl-alert> <div diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue index b4d565991f5..c1246b2bf44 100644 --- a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue +++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue @@ -2,6 +2,7 @@ import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; const DEFAULT_PAGE_SIZES = [20, 50, 100]; @@ -12,6 +13,7 @@ export default { GlDropdownItem, GlIcon, GlSprintf, + LocalStorageSync, }, props: { pageInfo: { @@ -23,6 +25,11 @@ export default { type: Array, default: () => DEFAULT_PAGE_SIZES, }, + storageKey: { + required: false, + type: String, + default: null, + }, }, computed: { @@ -66,6 +73,12 @@ export default { <template> <div class="gl-display-flex gl-align-items-center"> + <local-storage-sync + v-if="storageKey" + :storage-key="storageKey" + :value="pageInfo.perPage" + @input="setPageSize" + /> <pagination-links :change="setPage" :page-info="pageInfo" class="gl-m-0" /> <gl-dropdown category="tertiary" class="gl-ml-auto" data-testid="page-size"> <template #button-content> diff --git a/app/assets/javascripts/vue_shared/components/registry/history_item.vue b/app/assets/javascripts/vue_shared/components/registry/history_item.vue index a60b630b207..384b084ce09 100644 --- a/app/assets/javascripts/vue_shared/components/registry/history_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/history_item.vue @@ -18,15 +18,15 @@ export default { </script> <template> - <timeline-entry-item class="system-note note-wrapper gl-mb-6!"> + <timeline-entry-item class="system-note note-wrapper"> <div class="timeline-icon"> <gl-icon :name="icon" /> </div> <div class="timeline-content"> <div class="note-header"> - <span> + <div class="note-header-info"> <slot></slot> - </span> + </div> </div> <div class="note-body"> <slot name="body"></slot> diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue index 1948a6778f4..8c9c7c63db1 100644 --- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue +++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue @@ -1,6 +1,7 @@ <script> import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { SORT_DIRECTION_UI } from '~/search/sort/constants'; const ASCENDING_ORDER = 'asc'; const DESCENDING_ORDER = 'desc'; @@ -52,6 +53,9 @@ export default { return acc; }, {}); }, + sortDirectionData() { + return this.isSortAscending ? SORT_DIRECTION_UI.asc : SORT_DIRECTION_UI.desc; + }, }, methods: { generateQueryData({ sorting = {}, filter = [] } = {}) { @@ -119,6 +123,7 @@ export default { data-testid="registry-sort-dropdown" :text="sortText" :is-ascending="isSortAscending" + :sort-direction-tool-tip="sortDirectionData.tooltip" @sortDirectionChange="onDirectionChange" > <gl-sorting-item diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index b61996cdcdb..e6c29e24f0c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -53,6 +53,11 @@ export default { required: false, default: false, }, + allowMultipleScopedLabels: { + type: Boolean, + required: false, + default: false, + }, variant: { type: String, required: false, @@ -164,6 +169,7 @@ export default { allowLabelCreate: this.allowLabelCreate, allowMultiselect: this.allowMultiselect, allowScopedLabels: this.allowScopedLabels, + allowMultipleScopedLabels: this.allowMultipleScopedLabels, dropdownButtonText: this.dropdownButtonText, selectedLabels: this.selectedLabels, labelsFetchPath: this.labelsFetchPath, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js index 0c697e624ab..2dab97826b9 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import * as types from './mutation_types'; @@ -16,7 +16,7 @@ export const receiveLabelsSuccess = ({ commit }, labels) => commit(types.RECEIVE_SET_LABELS_SUCCESS, labels); export const receiveLabelsFailure = ({ commit }) => { commit(types.RECEIVE_SET_LABELS_FAILURE); - createFlash({ + createAlert({ message: __('Error fetching labels.'), }); }; @@ -38,7 +38,7 @@ export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LA export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS); export const receiveCreateLabelFailure = ({ commit }) => { commit(types.RECEIVE_CREATE_LABEL_FAILURE); - createFlash({ + createAlert({ message: __('Error creating label.'), }); }; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js index 43b23994cdf..c85d9befcbb 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js @@ -94,14 +94,13 @@ export default { candidateLabel.indeterminate = false; } - if (isScopedLabel(candidateLabel)) { + if (isScopedLabel(candidateLabel) && !state.allowMultipleScopedLabels) { const currentActiveScopedLabel = state.labels.find( ({ set, title }) => set && title !== candidateLabel.title && scopedLabelKey({ title }) === scopedLabelKey(candidateLabel), ); - if (currentActiveScopedLabel) { currentActiveScopedLabel.set = false; } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue index 5f344ae4214..ce93ad216ec 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue @@ -8,7 +8,7 @@ import { GlLoadingIcon, } from '@gitlab/ui'; import produce from 'immer'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { workspaceLabelsQueries } from '~/sidebar/constants'; import createLabelMutation from './graphql/create_label.mutation.graphql'; @@ -129,7 +129,7 @@ export default { this.$emit('hideCreateView'); } } catch { - createFlash({ message: errorMessage }); + createAlert({ message: errorMessage }); } this.labelCreateInProgress = false; }, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue index 8d3d4d5f86a..1d854505d11 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue @@ -1,7 +1,7 @@ <script> import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { workspaceLabelsQueries } from '~/sidebar/constants'; @@ -62,7 +62,7 @@ export default { }, update: (data) => data.workspace?.labels?.nodes || [], error() { - createFlash({ message: __('Error fetching labels.') }); + createAlert({ message: __('Error fetching labels.') }); }, }, }, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue index 522fbc07f5e..0e8da7281d8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue @@ -2,7 +2,7 @@ import { debounce } from 'lodash'; import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql'; import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { IssuableType } from '~/issues/constants'; @@ -151,7 +151,7 @@ export default { return data.workspace?.issuable; }, error() { - createFlash({ message: __('Error fetching labels.') }); + createAlert({ message: __('Error fetching labels.') }); }, subscribeToMore: { document() { @@ -275,7 +275,7 @@ export default { }); }) .catch((error) => - createFlash({ + createAlert({ message: __('An error occurred while updating labels.'), captureError: true, error, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql index 445817d3e52..eae5e96ac46 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql @@ -1,7 +1,7 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user_availability.fragment.graphql" -query issueParticipants($fullPath: ID!, $iid: String!) { +query issueParticipants($fullPath: ID!, $iid: String!, $getStatus: Boolean = false) { workspace: project(fullPath: $fullPath) { id issuable: issue(iid: $iid) { @@ -9,7 +9,7 @@ query issueParticipants($fullPath: ID!, $iid: String!) { participants { nodes { ...User - ...UserAvailability + ...UserAvailability @include(if: $getStatus) } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql index 05de680ab05..f087ca6c982 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql @@ -19,7 +19,7 @@ query mergeRequestReviewers($fullPath: ID!, $iid: String!) { } } userPermissions { - updateMergeRequest + adminMergeRequest } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql index 3496d5f4a2e..2781ac71f31 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql @@ -1,7 +1,7 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user_availability.fragment.graphql" -query getMrParticipants($fullPath: ID!, $iid: String!) { +query getMrParticipants($fullPath: ID!, $iid: String!, $getStatus: Boolean = false) { workspace: project(fullPath: $fullPath) { id issuable: mergeRequest(iid: $iid) { @@ -9,7 +9,7 @@ query getMrParticipants($fullPath: ID!, $iid: String!) { participants { nodes { ...User - ...UserAvailability + ...UserAvailability @include(if: $getStatus) } } } diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue index 257b9f57222..ffd0eea63a1 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue @@ -1,8 +1,6 @@ <script> import { GlSafeHtmlDirective } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { setAttributes } from '~/lib/utils/dom_utils'; -import { BIDI_CHARS, BIDI_CHARS_CLASS_LIST, BIDI_CHAR_TOOLTIP } from '../constants'; export default { directives: { @@ -27,34 +25,6 @@ export default { required: true, }, }, - computed: { - formattedContent() { - let { content } = this; - - BIDI_CHARS.forEach((bidiChar) => { - if (content.includes(bidiChar)) { - content = content.replace(bidiChar, this.wrapBidiChar(bidiChar)); - } - }); - - return content; - }, - }, - methods: { - wrapBidiChar(bidiChar) { - const span = document.createElement('span'); - - setAttributes(span, { - class: BIDI_CHARS_CLASS_LIST, - title: BIDI_CHAR_TOOLTIP, - 'data-testid': 'bidi-wrapper', - }); - - span.innerText = bidiChar; - - return span.outerHTML; - }, - }, }; </script> <template> @@ -78,7 +48,7 @@ export default { </div> <pre - class="gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-normal" - ><code><span :id="`LC${number}`" v-safe-html="formattedContent" :lang="language" class="line" data-testid="content"></span></code></pre> + class="gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0" + ><code><span :id="`LC${number}`" v-safe-html="content" :lang="language" class="line" data-testid="content"></span></code></pre> </div> </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 30f57f506a6..a28460dd58e 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -1,5 +1,3 @@ -import { __ } from '~/locale'; - // Language map from Rouge::Lexer to highlight.js // Rouge::Lexer - We use it on the BE to determine the language of a source file (https://github.com/rouge-ruby/rouge/blob/master/docs/Languages.md). // Highlight.js - We use it on the FE to highlight the syntax of a source file (https://github.com/highlightjs/highlight.js/tree/main/src/languages). @@ -139,13 +137,6 @@ export const BIDI_CHARS = [ export const BIDI_CHARS_CLASS_LIST = 'unicode-bidi has-tooltip'; -export const BIDI_CHAR_TOOLTIP = __( - 'Potentially unwanted character detected: Unicode BiDi Control', -); - -export const HLJS_COMMENT_SELECTOR = 'hljs-comment'; +export const BIDI_CHAR_TOOLTIP = 'Potentially unwanted character detected: Unicode BiDi Control'; export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight'; - -export const NPM_URL = 'https://npmjs.com/package'; -export const GEM_URL = 'https://rubygems.org/gems'; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js index 5d24a3d110b..d694adf7147 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js @@ -1,6 +1,8 @@ -import { HLJS_ON_AFTER_HIGHLIGHT } from '../constants'; -import wrapComments from './wrap_comments'; +import wrapChildNodes from './wrap_child_nodes'; import linkDependencies from './link_dependencies'; +import wrapBidiChars from './wrap_bidi_chars'; + +export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight'; /** * Registers our plugins for Highlight.js @@ -10,7 +12,8 @@ import linkDependencies from './link_dependencies'; * @param {Object} hljs - the Highlight.js instance. */ export const registerPlugins = (hljs, fileType, rawContent) => { - hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapComments }); + hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapChildNodes }); + hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapBidiChars }); hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: (result) => linkDependencies(result, fileType, rawContent), }); diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js index dbe6812cf16..49704421d6e 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js @@ -1,16 +1,7 @@ import { escape } from 'lodash'; -import { setAttributes } from '~/lib/utils/dom_utils'; -export const createLink = (href, innerText) => { - // eslint-disable-next-line @gitlab/require-i18n-strings - const rel = 'nofollow noreferrer noopener'; - const link = document.createElement('a'); - - setAttributes(link, { href: escape(href), rel }); - link.textContent = innerText; - - return link.outerHTML; -}; +export const createLink = (href, innerText) => + `<a href="${escape(href)}" rel="nofollow noreferrer noopener">${escape(innerText)}</a>`; export const generateHLJSOpenTag = (type, delimiter = '"') => `<span class="hljs-${escape(type)}">${delimiter}`; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js index 35de8fd13d6..46c9dc38300 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js @@ -1,7 +1,6 @@ -import { joinPaths } from '~/lib/utils/url_utility'; -import { GEM_URL } from '../../constants'; import { createLink, generateHLJSOpenTag } from './dependency_linker_util'; +const GEM_URL = 'https://rubygems.org/gems/'; const methodRegex = '.*add_dependency.*|.*add_runtime_dependency.*|.*add_development_dependency.*'; const openTagRegex = generateHLJSOpenTag('string', '(&.*;)'); const closeTagRegex = '&.*</span>'; @@ -24,7 +23,7 @@ const DEPENDENCY_REGEX = new RegExp( const handleReplace = (method, delimiter, packageName, closeTag, rest) => { // eslint-disable-next-line @gitlab/require-i18n-strings const openTag = generateHLJSOpenTag('string linked', delimiter); - const href = joinPaths(GEM_URL, packageName); + const href = `${GEM_URL}${packageName}`; const packageLink = createLink(href, packageName); return `${method}${openTag}${packageLink}${closeTag}${rest}`; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js index 3c6fc23c138..4bfd5ec2431 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js @@ -1,8 +1,7 @@ import { unescape } from 'lodash'; -import { joinPaths } from '~/lib/utils/url_utility'; -import { NPM_URL } from '../../constants'; import { createLink, generateHLJSOpenTag } from './dependency_linker_util'; +const NPM_URL = 'https://npmjs.com/package/'; const attrOpenTag = generateHLJSOpenTag('attr'); const stringOpenTag = generateHLJSOpenTag('string'); const closeTag = '"</span>'; @@ -20,7 +19,7 @@ const DEPENDENCY_REGEX = new RegExp( const handleReplace = (original, packageName, version, dependenciesToLink) => { const unescapedPackageName = unescape(packageName); const unescapedVersion = unescape(version); - const href = joinPaths(NPM_URL, unescapedPackageName); + const href = `${NPM_URL}${unescapedPackageName}`; const packageLink = createLink(href, unescapedPackageName); const versionLink = createLink(href, unescapedVersion); const closeAndOpenTag = `${closeTag}: ${attrOpenTag}`; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_bidi_chars.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_bidi_chars.js new file mode 100644 index 00000000000..3b6cd96ef78 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_bidi_chars.js @@ -0,0 +1,30 @@ +import { + BIDI_CHARS, + BIDI_CHARS_CLASS_LIST, + BIDI_CHAR_TOOLTIP, +} from '~/vue_shared/components/source_viewer/constants'; + +/** + * Highlight.js plugin for wrapping BIDI chars. + * This ensures potentially dangerous BIDI characters are highlighted. + * + * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst + * + * @param {Object} Result - an object that represents the highlighted result from Highlight.js + */ + +function wrapBidiChar(bidiChar) { + return `<span class="${BIDI_CHARS_CLASS_LIST}" title="${BIDI_CHAR_TOOLTIP}">${bidiChar}</span>`; +} + +export default (result) => { + let { value } = result; + BIDI_CHARS.forEach((bidiChar) => { + if (value.includes(bidiChar)) { + value = value.replace(bidiChar, wrapBidiChar(bidiChar)); + } + }); + + // eslint-disable-next-line no-param-reassign + result.value = value; // Highlight.js expects the result param to be mutated for plugins to work +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js new file mode 100644 index 00000000000..e0ba4b730a7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js @@ -0,0 +1,45 @@ +import { escape } from 'lodash'; + +/** + * Highlight.js plugin for wrapping nodes with the correct selectors to ensure + * child-elements are highlighted correctly after we split up the result into chunks and lines. + * + * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst + * + * @param {Object} Result - an object that represents the highlighted result from Highlight.js + */ +const newlineRegex = /\r?\n/; +const generateClassName = (suffix) => (suffix ? `hljs-${escape(suffix)}` : ''); +const generateCloseTag = (includeClose) => (includeClose ? '</span>' : ''); +const generateHLJSTag = (kind, content = '', includeClose) => + `<span class="${generateClassName(kind)}">${escape(content)}${generateCloseTag(includeClose)}`; + +const format = (node, kind = '') => { + let buffer = ''; + + if (typeof node === 'string') { + buffer += node + .split(newlineRegex) + .map((newline) => generateHLJSTag(kind, newline, true)) + .join('\n'); + } else if (node.kind) { + const { children } = node; + if (children.length && children.length === 1) { + buffer += format(children[0], node.kind); + } else { + buffer += generateHLJSTag(node.kind); + children.forEach((subChild) => { + buffer += format(subChild, node.kind); + }); + buffer += `</span>`; + } + } + + return buffer; +}; + +export default (result) => { + // NOTE: We're using the private Emitter API here as we expect the Emitter API to be publicly available soon (https://github.com/highlightjs/highlight.js/issues/3621) + // eslint-disable-next-line no-param-reassign, no-underscore-dangle + result.value = result._emitter.rootNode.children.reduce((val, node) => val + format(node), ''); // Highlight.js expects the result param to be mutated for plugins to work +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js deleted file mode 100644 index 8b52df83fdf..00000000000 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js +++ /dev/null @@ -1,41 +0,0 @@ -import { HLJS_COMMENT_SELECTOR } from '../constants'; - -const createWrapper = (content) => { - const span = document.createElement('span'); - span.className = HLJS_COMMENT_SELECTOR; - - // eslint-disable-next-line no-unsanitized/property - span.innerHTML = content; - return span.outerHTML; -}; - -/** - * Highlight.js plugin for wrapping multi-line comments in the `hljs-comment` class. - * This ensures that multi-line comments are rendered correctly in the GitLab UI. - * - * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst - * - * @param {Object} Result - an object that represents the highlighted result from Highlight.js - */ -export default (result) => { - if (!result.value.includes(HLJS_COMMENT_SELECTOR)) return; - - let wrapComment = false; - - // eslint-disable-next-line no-param-reassign - result.value = result.value // Highlight.js expects the result param to be mutated for plugins to work - .split('\n') - .map((lineContent) => { - const includesClosingTag = lineContent.includes('</span>'); - if (lineContent.includes(HLJS_COMMENT_SELECTOR) && !includesClosingTag) { - wrapComment = true; - return lineContent; - } - const line = wrapComment ? createWrapper(lineContent) : lineContent; - if (includesClosingTag) { - wrapComment = false; - } - return line; - }) - .join('\n'); -}; 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 9c6c12eac7d..536b2c8a281 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 @@ -53,7 +53,7 @@ export default { }, computed: { splitContent() { - return this.content.split('\n'); + return this.content.split(/\r?\n/); }, lineNumbers() { return this.splitContent.length; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight.js b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight.js new file mode 100644 index 00000000000..535e857d7a9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight.js @@ -0,0 +1,10 @@ +import { highlight } from './highlight_utils'; + +/** + * A webworker for highlighting large amounts of content with Highlight.js + */ +// eslint-disable-next-line no-restricted-globals +self.addEventListener('message', ({ data: { fileType, content, language } }) => { + // eslint-disable-next-line no-restricted-globals + self.postMessage(highlight(fileType, content, language)); +}); 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 new file mode 100644 index 00000000000..0da57f9e6fa --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js @@ -0,0 +1,15 @@ +import hljs from 'highlight.js/lib/core'; +import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import { registerPlugins } from '../plugins/index'; + +const initHighlightJs = async (fileType, content, language) => { + const languageDefinition = await languageLoader[language](); + + 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; +}; diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue deleted file mode 100644 index ce65266cbc9..00000000000 --- a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue +++ /dev/null @@ -1,88 +0,0 @@ -<script> -import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; -import { secondsToHours } from '~/lib/utils/datetime_utility'; -import { __ } from '~/locale'; -import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; - -export default { - name: 'TimezoneDropdown', - components: { - GlDropdown, - GlDropdownItem, - GlSearchBoxByType, - }, - directives: { - autofocusonshow, - }, - props: { - value: { - type: String, - required: true, - default: '', - }, - timezoneData: { - type: Array, - required: true, - default: () => [], - }, - }, - data() { - return { - searchTerm: '', - }; - }, - tranlations: { - noResultsText: __('No matching results'), - }, - computed: { - timezones() { - return this.timezoneData.map((timezone) => ({ - formattedTimezone: this.formatTimezone(timezone), - identifier: timezone.identifier, - })); - }, - filteredResults() { - const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); - return this.timezones.filter((timezone) => - timezone.formattedTimezone.toLowerCase().includes(lowerCasedSearchTerm), - ); - }, - selectedTimezoneLabel() { - return this.value || __('Select timezone'); - }, - }, - methods: { - selectTimezone(selectedTimezone) { - this.$emit('input', selectedTimezone); - this.searchTerm = ''; - }, - isSelected(timezone) { - return this.value === timezone.formattedTimezone; - }, - formatTimezone(item) { - return `[UTC ${secondsToHours(item.offset)}] ${item.name}`; - }, - }, -}; -</script> -<template> - <gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!" v-bind="$attrs"> - <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus /> - <gl-dropdown-item - v-for="timezone in filteredResults" - :key="timezone.formattedTimezone" - :is-checked="isSelected(timezone)" - is-check-item - @click="selectTimezone(timezone)" - > - {{ timezone.formattedTimezone }} - </gl-dropdown-item> - <gl-dropdown-item - v-if="!filteredResults.length" - class="gl-pointer-events-none" - data-testid="noMatchingResults" - > - {{ $options.tranlations.noResultsText }} - </gl-dropdown-item> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue new file mode 100644 index 00000000000..423501265d7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue @@ -0,0 +1,119 @@ +<script> +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { __ } from '~/locale'; +import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; +import { formatTimezone } from '~/lib/utils/datetime_utility'; + +export default { + name: 'TimezoneDropdown', + components: { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + }, + directives: { + autofocusonshow, + }, + props: { + value: { + type: String, + required: true, + default: '', + }, + name: { + type: String, + required: false, + default: '', + }, + timezoneData: { + type: Array, + required: true, + default: () => [], + }, + }, + data() { + return { + searchTerm: '', + tzValue: this.initialTimezone(this.timezoneData, this.value), + }; + }, + translations: { + noResultsText: __('No matching results'), + }, + computed: { + timezones() { + return this.timezoneData.map((timezone) => ({ + formattedTimezone: formatTimezone(timezone), + identifier: timezone.identifier, + })); + }, + filteredResults() { + const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + return this.timezones.filter((timezone) => + timezone.formattedTimezone.toLowerCase().includes(lowerCasedSearchTerm), + ); + }, + selectedTimezoneLabel() { + return this.tzValue || __('Select timezone'); + }, + timezoneIdentifier() { + return this.tzValue + ? this.timezones.find((timezone) => timezone.formattedTimezone === this.tzValue).identifier + : undefined; + }, + }, + methods: { + selectTimezone(selectedTimezone) { + this.tzValue = selectedTimezone.formattedTimezone; + this.$emit('input', selectedTimezone); + this.searchTerm = ''; + }, + isSelected(timezone) { + return this.tzValue === timezone.formattedTimezone; + }, + initialTimezone(timezones, value) { + if (!value) { + return undefined; + } + + const initialTimezone = timezones.find((timezone) => timezone.identifier === value); + + if (initialTimezone) { + return formatTimezone(initialTimezone); + } + + return undefined; + }, + }, +}; +</script> +<template> + <div> + <input + v-if="name" + id="user_timezone" + :name="name" + :value="timezoneIdentifier || value" + type="hidden" + /> + <gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!" v-bind="$attrs"> + <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus /> + <gl-dropdown-item + v-for="timezone in filteredResults" + :key="timezone.formattedTimezone" + :is-checked="isSelected(timezone)" + is-check-item + @click="selectTimezone(timezone)" + > + {{ timezone.formattedTimezone }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="!filteredResults.length" + class="gl-pointer-events-none" + data-testid="noMatchingResults" + > + {{ $options.translations.noResultsText }} + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/url_sync.vue b/app/assets/javascripts/vue_shared/components/url_sync.vue index 925c6008836..bd5b7b77017 100644 --- a/app/assets/javascripts/vue_shared/components/url_sync.vue +++ b/app/assets/javascripts/vue_shared/components/url_sync.vue @@ -1,6 +1,9 @@ <script> import { historyPushState } from '~/lib/utils/common_utils'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { mergeUrlParams, setUrlParams } from '~/lib/utils/url_utility'; + +export const URL_SET_PARAMS_STRATEGY = 'set'; +export const URL_MERGE_PARAMS_STRATEGY = 'merge'; /** * Renderless component to update the query string, @@ -15,6 +18,12 @@ export default { required: false, default: null, }, + urlParamsUpdateStrategy: { + type: String, + required: false, + default: URL_MERGE_PARAMS_STRATEGY, + validator: (value) => [URL_MERGE_PARAMS_STRATEGY, URL_SET_PARAMS_STRATEGY].includes(value), + }, }, watch: { query: { @@ -29,7 +38,11 @@ export default { }, methods: { updateQuery(newQuery) { - historyPushState(mergeUrlParams(newQuery, window.location.href, { spreadArrays: true })); + const url = + this.urlParamsUpdateStrategy === URL_SET_PARAMS_STRATEGY + ? setUrlParams(this.query, window.location.href, true) + : mergeUrlParams(newQuery, window.location.href, { spreadArrays: true }); + historyPushState(url); }, }, render() { diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index c1e618620d8..6552a874c3a 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -5,29 +5,29 @@ Sample configuration: - <user-avatar-image + <user-avatar lazy :img-src="userAvatarSrc" :img-alt="tooltipText" :tooltip-text="tooltipText" tooltip-placement="top" + :size="24" /> */ +import { GlTooltip, GlAvatar } from '@gitlab/ui'; +import { isObject } from 'lodash'; import defaultAvatarUrl from 'images/no_avatar.png'; import { __ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import UserAvatarImageNew from './user_avatar_image_new.vue'; -import UserAvatarImageOld from './user_avatar_image_old.vue'; +import { placeholderImage } from '~/lazy_loader'; export default { name: 'UserAvatarImage', components: { - UserAvatarImageNew, - UserAvatarImageOld, + GlTooltip, + GlAvatar, }, - mixins: [glFeatureFlagMixin()], props: { lazy: { type: Boolean, @@ -51,8 +51,7 @@ export default { }, size: { type: [Number, Object], - required: false, - default: 20, + required: true, }, tooltipText: { type: String, @@ -64,22 +63,52 @@ export default { required: false, default: 'top', }, - enforceGlAvatar: { - type: Boolean, - required: false, + }, + computed: { + // API response sends null when gravatar is disabled and + // we provide an empty string when we use it inside user avatar link. + // In both cases we should render the defaultAvatarUrl + sanitizedSource() { + let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; + // Only adds the width to the URL if its not a base64 data image + if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?')) + baseSrc += `?width=${this.maximumSize}`; + return baseSrc; + }, + maximumSize() { + if (isObject(this.size)) { + return Math.max(...Object.values(this.size)); + } + + return this.size; + }, + resultantSrcAttribute() { + return this.lazy ? placeholderImage : this.sanitizedSource; }, }, }; </script> <template> - <user-avatar-image-new - v-if="glFeatures.glAvatarForAllUserAvatars || enforceGlAvatar" - v-bind="$props" - > - <slot></slot> - </user-avatar-image-new> - <user-avatar-image-old v-else v-bind="$props"> - <slot></slot> - </user-avatar-image-old> + <span ref="userAvatar"> + <gl-avatar + :class="{ + lazy: lazy, + [cssClasses]: true, + }" + :src="resultantSrcAttribute" + :data-src="sanitizedSource" + :size="size" + :alt="imgAlt" + /> + + <gl-tooltip + v-if="tooltipText || $scopedSlots.default" + :target="() => $refs.userAvatar" + :placement="tooltipPlacement" + boundary="window" + > + <slot>{{ tooltipText }}</slot> + </gl-tooltip> + </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue deleted file mode 100644 index 6bd66981860..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue +++ /dev/null @@ -1,117 +0,0 @@ -<script> -/* This is a re-usable vue component for rendering a user avatar that - does not need to link to the user's profile. The image and an optional - tooltip can be configured by props passed to this component. - - Sample configuration: - - <user-avatar - lazy - :img-src="userAvatarSrc" - :img-alt="tooltipText" - :tooltip-text="tooltipText" - tooltip-placement="top" - /> - - */ - -import { GlTooltip, GlAvatar } from '@gitlab/ui'; -import { isObject } from 'lodash'; -import defaultAvatarUrl from 'images/no_avatar.png'; -import { __ } from '~/locale'; -import { placeholderImage } from '~/lazy_loader'; - -export default { - name: 'UserAvatarImageNew', - components: { - GlTooltip, - GlAvatar, - }, - props: { - lazy: { - type: Boolean, - required: false, - default: false, - }, - imgSrc: { - type: String, - required: false, - default: defaultAvatarUrl, - }, - cssClasses: { - type: String, - required: false, - default: '', - }, - imgAlt: { - type: String, - required: false, - default: __('user avatar'), - }, - size: { - type: [Number, Object], - required: false, - default: 20, - }, - tooltipText: { - type: String, - required: false, - default: '', - }, - tooltipPlacement: { - type: String, - required: false, - default: 'top', - }, - }, - computed: { - // API response sends null when gravatar is disabled and - // we provide an empty string when we use it inside user avatar link. - // In both cases we should render the defaultAvatarUrl - sanitizedSource() { - let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; - // Only adds the width to the URL if its not a base64 data image - if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?')) - baseSrc += `?width=${this.maximumSize}`; - return baseSrc; - }, - maximumSize() { - if (isObject(this.size)) { - return Math.max(...Object.values(this.size)); - } - - return this.size; - }, - resultantSrcAttribute() { - return this.lazy ? placeholderImage : this.sanitizedSource; - }, - }, -}; -</script> - -<template> - <span ref="userAvatar"> - <gl-avatar - :class="{ - lazy: lazy, - [cssClasses]: true, - }" - :src="resultantSrcAttribute" - :data-src="sanitizedSource" - :size="size" - :alt="imgAlt" - /> - - <gl-tooltip - v-if=" - tooltipText || - $slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ - " - :target="() => $refs.userAvatar" - :placement="tooltipPlacement" - boundary="window" - > - <slot>{{ tooltipText }}</slot> - </gl-tooltip> - </span> -</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue deleted file mode 100644 index 6e8c200d5c3..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue +++ /dev/null @@ -1,114 +0,0 @@ -<script> -/* This is a re-usable vue component for rendering a user avatar that - does not need to link to the user's profile. The image and an optional - tooltip can be configured by props passed to this component. - - Sample configuration: - - <user-avatar-image - lazy - :img-src="userAvatarSrc" - :img-alt="tooltipText" - :tooltip-text="tooltipText" - tooltip-placement="top" - /> - - */ - -import { GlTooltip } from '@gitlab/ui'; -import defaultAvatarUrl from 'images/no_avatar.png'; -import { __ } from '~/locale'; -import { placeholderImage } from '~/lazy_loader'; - -export default { - name: 'UserAvatarImageOld', - components: { - GlTooltip, - }, - props: { - lazy: { - type: Boolean, - required: false, - default: false, - }, - imgSrc: { - type: String, - required: false, - default: defaultAvatarUrl, - }, - cssClasses: { - type: String, - required: false, - default: '', - }, - imgAlt: { - type: String, - required: false, - default: __('user avatar'), - }, - size: { - type: Number, - required: false, - default: 20, - }, - tooltipText: { - type: String, - required: false, - default: '', - }, - tooltipPlacement: { - type: String, - required: false, - default: 'top', - }, - }, - computed: { - // API response sends null when gravatar is disabled and - // we provide an empty string when we use it inside user avatar link. - // In both cases we should render the defaultAvatarUrl - sanitizedSource() { - let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; - // Only adds the width to the URL if its not a base64 data image - if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?')) - baseSrc += `?width=${this.size}`; - return baseSrc; - }, - resultantSrcAttribute() { - return this.lazy ? placeholderImage : this.sanitizedSource; - }, - avatarSizeClass() { - return `s${this.size}`; - }, - }, -}; -</script> - -<template> - <span> - <img - ref="userAvatarImage" - :class="{ - lazy: lazy, - [avatarSizeClass]: true, - [cssClasses]: true, - }" - :src="resultantSrcAttribute" - :width="size" - :height="size" - :alt="imgAlt" - :data-src="sanitizedSource" - class="avatar" - /> - <gl-tooltip - v-if=" - tooltipText || - $slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ - " - :target="() => $refs.userAvatarImage" - :placement="tooltipPlacement" - boundary="window" - > - <slot>{{ tooltipText }}</slot> - </gl-tooltip> - </span> -</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index f80abed4d69..1a81da3eb0d 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -9,7 +9,7 @@ :link-href="userProfileUrl" :img-src="userAvatarSrc" :img-alt="tooltipText" - :img-size="20" + :img-size="32" :tooltip-text="tooltipText" :tooltip-placement="top" :username="username" @@ -17,17 +17,18 @@ */ -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import UserAvatarLinkNew from './user_avatar_link_new.vue'; -import UserAvatarLinkOld from './user_avatar_link_old.vue'; +import { GlAvatarLink, GlTooltipDirective } from '@gitlab/ui'; +import UserAvatarImage from './user_avatar_image.vue'; export default { - name: 'UserAvatarLink', + name: 'UserAvatarLinkNew', components: { - UserAvatarLinkNew, - UserAvatarLinkOld, + UserAvatarImage, + GlAvatarLink, + }, + directives: { + GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], props: { lazy: { type: Boolean, @@ -56,8 +57,7 @@ export default { }, imgSize: { type: [Number, Object], - required: false, - default: 20, + required: true, }, tooltipText: { type: String, @@ -74,29 +74,43 @@ export default { required: false, default: '', }, - enforceGlAvatar: { - type: Boolean, - required: false, + }, + computed: { + shouldShowUsername() { + return this.username.length > 0; + }, + avatarTooltipText() { + return this.shouldShowUsername ? '' : this.tooltipText; }, }, }; </script> <template> - <user-avatar-link-new - v-if="glFeatures.glAvatarForAllUserAvatars || enforceGlAvatar" - v-bind="$props" - > - <slot></slot> - <template #avatar-badge> - <slot name="avatar-badge"></slot> - </template> - </user-avatar-link-new> + <gl-avatar-link :href="linkHref" class="user-avatar-link"> + <user-avatar-image + :img-src="imgSrc" + :img-alt="imgAlt" + :css-classes="imgCssClasses" + :size="imgSize" + :tooltip-text="avatarTooltipText" + :tooltip-placement="tooltipPlacement" + :lazy="lazy" + > + <slot></slot> + </user-avatar-image> + + <span + v-if="shouldShowUsername" + v-gl-tooltip + :title="tooltipText" + :tooltip-placement="tooltipPlacement" + class="gl-ml-3" + data-testid="user-avatar-link-username" + > + {{ username }} + </span> - <user-avatar-link-old v-else v-bind="$props"> - <slot></slot> - <template #avatar-badge> - <slot name="avatar-badge"></slot> - </template> - </user-avatar-link-old> + <slot name="avatar-badge"></slot> + </gl-avatar-link> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue deleted file mode 100644 index 83551c689c4..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue +++ /dev/null @@ -1,122 +0,0 @@ -<script> -/* This is a re-usable vue component for rendering a user avatar wrapped in - a clickable link (likely to the user's profile). The link, image, and - tooltip can be configured by props passed to this component. - - Sample configuration: - - <user-avatar-link - :link-href="userProfileUrl" - :img-src="userAvatarSrc" - :img-alt="tooltipText" - :img-size="20" - :tooltip-text="tooltipText" - :tooltip-placement="top" - :username="username" - /> - -*/ - -import { GlAvatarLink, GlTooltipDirective } from '@gitlab/ui'; -import UserAvatarImage from './user_avatar_image.vue'; - -export default { - name: 'UserAvatarLinkNew', - components: { - UserAvatarImage, - GlAvatarLink, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - lazy: { - type: Boolean, - required: false, - default: false, - }, - linkHref: { - type: String, - required: false, - default: '', - }, - imgSrc: { - type: String, - required: false, - default: '', - }, - imgAlt: { - type: String, - required: false, - default: '', - }, - imgCssClasses: { - type: String, - required: false, - default: '', - }, - imgSize: { - type: [Number, Object], - required: false, - default: 20, - }, - tooltipText: { - type: String, - required: false, - default: '', - }, - tooltipPlacement: { - type: String, - required: false, - default: 'top', - }, - username: { - type: String, - required: false, - default: '', - }, - enforceGlAvatar: { - type: Boolean, - required: false, - }, - }, - computed: { - shouldShowUsername() { - return this.username.length > 0; - }, - avatarTooltipText() { - return this.shouldShowUsername ? '' : this.tooltipText; - }, - }, -}; -</script> - -<template> - <gl-avatar-link :href="linkHref" class="user-avatar-link"> - <user-avatar-image - :img-src="imgSrc" - :img-alt="imgAlt" - :css-classes="imgCssClasses" - :size="imgSize" - :tooltip-text="avatarTooltipText" - :tooltip-placement="tooltipPlacement" - :lazy="lazy" - :enforce-gl-avatar="enforceGlAvatar" - > - <slot></slot> - </user-avatar-image> - - <span - v-if="shouldShowUsername" - v-gl-tooltip - :title="tooltipText" - :tooltip-placement="tooltipPlacement" - class="gl-ml-3" - data-testid="user-avatar-link-username" - > - {{ username }} - </span> - - <slot name="avatar-badge"></slot> - </gl-avatar-link> -</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue deleted file mode 100644 index c2e46e61e1b..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue +++ /dev/null @@ -1,117 +0,0 @@ -<script> -/* This is a re-usable vue component for rendering a user avatar wrapped in - a clickable link (likely to the user's profile). The link, image, and - tooltip can be configured by props passed to this component. - - Sample configuration: - - <user-avatar-link - :link-href="userProfileUrl" - :img-src="userAvatarSrc" - :img-alt="tooltipText" - :img-size="20" - :tooltip-text="tooltipText" - :tooltip-placement="top" - :username="username" - /> - -*/ - -import { GlLink, GlTooltipDirective } from '@gitlab/ui'; -import UserAvatarImage from './user_avatar_image.vue'; - -export default { - name: 'UserAvatarLinkOld', - components: { - GlLink, - UserAvatarImage, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - lazy: { - type: Boolean, - required: false, - default: false, - }, - linkHref: { - type: String, - required: false, - default: '', - }, - imgSrc: { - type: String, - required: false, - default: '', - }, - imgAlt: { - type: String, - required: false, - default: '', - }, - imgCssClasses: { - type: String, - required: false, - default: '', - }, - imgSize: { - type: Number, - required: false, - default: 20, - }, - tooltipText: { - type: String, - required: false, - default: '', - }, - tooltipPlacement: { - type: String, - required: false, - default: 'top', - }, - username: { - type: String, - required: false, - default: '', - }, - }, - computed: { - shouldShowUsername() { - return this.username.length > 0; - }, - avatarTooltipText() { - return this.shouldShowUsername ? '' : this.tooltipText; - }, - }, -}; -</script> - -<template> - <span> - <gl-link :href="linkHref" class="user-avatar-link"> - <user-avatar-image - :img-src="imgSrc" - :img-alt="imgAlt" - :css-classes="imgCssClasses" - :size="imgSize" - :tooltip-text="avatarTooltipText" - :tooltip-placement="tooltipPlacement" - :lazy="lazy" - > - <slot></slot> - </user-avatar-image> - - <span - v-if="shouldShowUsername" - v-gl-tooltip - :title="tooltipText" - :tooltip-placement="tooltipPlacement" - data-testid="user-avatar-link-username" - > - {{ username }} - </span> - <slot name="avatar-badge"></slot> - </gl-link> - </span> -</template> 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 9da298ad705..231f5ff3d1f 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 @@ -1,6 +1,5 @@ <script> import { GlButton } from '@gitlab/ui'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { sprintf, __ } from '~/locale'; import UserAvatarLink from './user_avatar_link.vue'; @@ -9,7 +8,6 @@ export default { UserAvatarLink, GlButton, }, - mixins: [glFeatureFlagMixin()], props: { items: { type: Array, @@ -22,8 +20,7 @@ export default { }, imgSize: { type: [Number, Object], - required: false, - default: 20, + required: true, }, emptyText: { type: String, @@ -59,9 +56,6 @@ export default { return sprintf(__('%{count} more'), { count }); }, - imgCssClasses() { - return this.glFeatures.glAvatarForAllUserAvatars ? 'gl-mr-3' : ''; - }, }, methods: { expand() { @@ -85,7 +79,7 @@ export default { :img-alt="item.name" :tooltip-text="item.name" :img-size="imgSize" - :img-css-classes="imgCssClasses" + img-css-classes="gl-mr-3" /> <template v-if="hasBreakpoint"> <gl-button v-if="hasHiddenItems" variant="link" @click="expand"> diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 4b39a8e45bb..80c1fcbacfa 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -10,7 +10,7 @@ import { GlAvatarLabeled, } from '@gitlab/ui'; import { glEmojiTag } from '~/emoji'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { followUser, unfollowUser } from '~/rest_api'; import { isUserBusy } from '~/set_status_modal/utils'; import Tracking from '~/tracking'; @@ -83,6 +83,8 @@ export default { return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message_html}`; } else if (this.user.status.message_html) { return this.user.status.message_html; + } else if (this.user.status.emoji) { + return glEmojiTag(this.user.status.emoji); } return ''; @@ -139,8 +141,9 @@ export default { await followUser(this.user.id); this.$emit('follow'); } catch (error) { - createFlash({ - message: I18N_ERROR_FOLLOW, + const message = error.response?.data?.message || I18N_ERROR_FOLLOW; + createAlert({ + message, error, captureError: true, }); @@ -159,7 +162,7 @@ export default { await unfollowUser(this.user.id); this.$emit('unfollow'); } catch (error) { - createFlash({ + createAlert({ message: I18N_ERROR_UNFOLLOW, error, captureError: true, 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 3180bd0d283..86a99b8f0ed 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 @@ -103,6 +103,7 @@ export default { return { iid: this.iid, fullPath: this.fullPath, + getStatus: true, }; }, update(data) { |