diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-30 14:02:35 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-30 14:02:35 +0300 |
commit | 434a0ce52d75e13d48eac9ce83774954c7c5d48d (patch) | |
tree | de3b7a7cf1ce8b07555f28df592297c76894c90f /app/assets/javascripts/vue_shared | |
parent | 0a0d9493ca481c56b739a3df27c31262283150fe (diff) |
Add latest changes from gitlab-org/gitlab@13-7-stable-eev13.7.0-rc2
Diffstat (limited to 'app/assets/javascripts/vue_shared')
50 files changed, 662 insertions, 1779 deletions
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index 7a687ea4ad0..6d5912df96b 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ import { groupBy } from 'lodash'; -import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { glEmojiTag } from '../../emoji'; import { __, sprintf } from '~/locale'; @@ -10,8 +10,8 @@ const NO_USER_ID = -1; export default { components: { + GlButton, GlIcon, - GlLoadingIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -64,7 +64,7 @@ export default { methods: { getAwardClassBindings(awardList) { return { - active: this.hasReactionByCurrentUser(awardList), + selected: this.hasReactionByCurrentUser(awardList), disabled: this.currentUserId === NO_USER_ID, }; }, @@ -150,40 +150,39 @@ export default { <template> <div class="awards js-awards-block"> - <button + <gl-button v-for="awardList in groupedAwards" :key="awardList.name" v-gl-tooltip.viewport + class="gl-mr-3" :class="awardList.classes" :title="awardList.title" data-testid="award-button" - class="btn award-control" - type="button" @click="handleAward(awardList.name)" > - <span data-testid="award-html" v-html="awardList.html"></span> - <span class="award-control-text js-counter">{{ awardList.list.length }}</span> - </button> + <template #emoji> + <span class="award-emoji-block" data-testid="award-html" v-html="awardList.html"></span> + </template> + <span class="js-counter">{{ awardList.list.length }}</span> + </gl-button> <div v-if="canAwardEmoji" class="award-menu-holder"> - <button + <gl-button v-gl-tooltip.viewport :class="addButtonClass" - class="award-control btn js-add-award" + class="add-reaction-button js-add-award" title="Add reaction" :aria-label="__('Add reaction')" - type="button" > - <span class="award-control-icon award-control-icon-neutral"> + <span class="reaction-control-icon reaction-control-icon-neutral"> <gl-icon aria-hidden="true" name="slight-smile" /> </span> - <span class="award-control-icon award-control-icon-positive"> + <span class="reaction-control-icon reaction-control-icon-positive"> <gl-icon aria-hidden="true" name="smiley" /> </span> - <span class="award-control-icon award-control-icon-super-positive"> - <gl-icon aria-hidden="true" name="smiley" /> + <span class="reaction-control-icon reaction-control-icon-super-positive"> + <gl-icon aria-hidden="true" name="smile" /> </span> - <gl-loading-icon size="md" color="dark" class="award-control-icon-loading" /> - </button> + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js index d4c1808eec2..106dd7a3b97 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js @@ -1,3 +1 @@ export const HIGHLIGHT_CLASS_NAME = 'hll'; - -export default {}; diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue index 328c7e3fd32..55526dcc381 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue @@ -77,7 +77,7 @@ export default { </script> <template> - <div> + <div data-testid="image-viewer"> <div :class="innerCssClasses" class="position-relative"> <img ref="contentImg" :src="path" @load="onImgLoad" /> <slot name="image-overlay"></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js index 40708453d79..aaadc9766db 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js @@ -89,5 +89,3 @@ export const inputStringToIsoDate = (value, utc = false) => { */ export const isoDateToInputString = (date, utc = false) => dateformat(date, dateFormats.inputFormat, utc); - -export default {}; diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index b4115b0c6a4..4d07d9fcfdd 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -143,6 +143,7 @@ export default { :style="levelIndentation" class="file-row-name" data-qa-selector="file_name_content" + data-testid="file-row-name-container" :class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]" > <file-icon diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue new file mode 100644 index 00000000000..c45666e69eb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue @@ -0,0 +1,90 @@ +<script> +import Tribute from 'tributejs'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import { GfmAutocompleteType, tributeConfig } from '~/vue_shared/components/gfm_autocomplete/utils'; + +export default { + errorMessage: __( + 'An error occurred while getting autocomplete data. Please refresh the page and try again.', + ), + props: { + autocompleteTypes: { + type: Array, + required: false, + default: () => Object.values(GfmAutocompleteType), + }, + dataSources: { + type: Object, + required: false, + default: () => gl.GfmAutoComplete?.dataSources || {}, + }, + }, + computed: { + config() { + return this.autocompleteTypes.map(type => ({ + ...tributeConfig[type].config, + values: this.getValues(type), + })); + }, + }, + mounted() { + this.cache = {}; + this.tribute = new Tribute({ collection: this.config }); + + const input = this.$slots.default?.[0]?.elm; + this.tribute.attach(input); + }, + beforeDestroy() { + const input = this.$slots.default?.[0]?.elm; + this.tribute.detach(input); + }, + methods: { + cacheAssignees() { + const isAssigneesLengthSame = + this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length; + + if (!this.assignees || !isAssigneesLengthSame) { + this.assignees = + SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || []; + } + }, + filterValues(type) { + // The assignees AJAX response can come after the user first invokes autocomplete + // so we need to check more than once if we need to update the assignee cache + this.cacheAssignees(); + + return tributeConfig[type].filterValues + ? tributeConfig[type].filterValues({ + assignees: this.assignees, + collection: this.cache[type], + fullText: this.$slots.default?.[0]?.elm?.value, + selectionStart: this.$slots.default?.[0]?.elm?.selectionStart, + }) + : this.cache[type]; + }, + getValues(type) { + return (inputText, processValues) => { + if (this.cache[type]) { + processValues(this.filterValues(type)); + } else if (this.dataSources[type]) { + axios + .get(this.dataSources[type]) + .then(response => { + this.cache[type] = response.data; + processValues(this.filterValues(type)); + }) + .catch(() => createFlash({ message: this.$options.errorMessage })); + } else { + processValues([]); + } + }; + }, + }, + render(createElement) { + return createElement('div', this.$slots.default); + }, +}; +</script> diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js new file mode 100644 index 00000000000..b2e995d0f17 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js @@ -0,0 +1,142 @@ +import { escape, last } from 'lodash'; +import { spriteIcon } from '~/lib/utils/common_utils'; + +const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings + +const nonWordOrInteger = /\W|^\d+$/; + +export const GfmAutocompleteType = { + Issues: 'issues', + Labels: 'labels', + Members: 'members', + MergeRequests: 'mergeRequests', + Milestones: 'milestones', + Snippets: 'snippets', +}; + +function doesCurrentLineStartWith(searchString, fullText, selectionStart) { + const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length; + const currentLine = fullText.split('\n')[currentLineNumber - 1]; + return currentLine.startsWith(searchString); +} + +export const tributeConfig = { + [GfmAutocompleteType.Issues]: { + config: { + trigger: '#', + lookup: value => value.iid + value.title, + menuItemTemplate: ({ original }) => + `<small>${original.reference || original.iid}</small> ${escape(original.title)}`, + selectTemplate: ({ original }) => original.reference || `#${original.iid}`, + }, + }, + + [GfmAutocompleteType.Labels]: { + config: { + trigger: '~', + lookup: 'title', + menuItemTemplate: ({ original }) => ` + <span class="dropdown-label-box" style="background: ${escape(original.color)};"></span> + ${escape(original.title)}`, + selectTemplate: ({ original }) => + nonWordOrInteger.test(original.title) + ? `~"${escape(original.title)}"` + : `~${escape(original.title)}`, + }, + filterValues({ collection, fullText, selectionStart }) { + if (doesCurrentLineStartWith('/label', fullText, selectionStart)) { + return collection.filter(label => !label.set); + } + + if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) { + return collection.filter(label => label.set); + } + + return collection; + }, + }, + + [GfmAutocompleteType.Members]: { + config: { + trigger: '@', + fillAttr: 'username', + lookup: value => + value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username, + menuItemTemplate: ({ original }) => { + const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0'; + const noAvatarClasses = `${commonClasses} gl-rounded-small + gl-display-flex gl-align-items-center gl-justify-content-center`; + + const avatar = original.avatar_url + ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />` + : `<div class="${noAvatarClasses}" aria-hidden="true"> + ${original.username.charAt(0).toUpperCase()}</div>`; + + let displayName = original.name; + let parentGroupOrUsername = `@${original.username}`; + + if (original.type === groupType) { + const splitName = original.name.split(' / '); + displayName = splitName.pop(); + parentGroupOrUsername = splitName.pop(); + } + + const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : ''; + + const disabledMentionsIcon = original.mentionsDisabled + ? spriteIcon('notifications-off', 's16 gl-ml-3') + : ''; + + return ` + <div class="gl-display-flex gl-align-items-center"> + ${avatar} + <div class="gl-font-sm gl-line-height-normal gl-ml-3"> + <div>${escape(displayName)}${count}</div> + <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div> + </div> + ${disabledMentionsIcon} + </div> + `; + }, + }, + filterValues({ assignees, collection, fullText, selectionStart }) { + if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) { + return collection.filter(member => !assignees.includes(member.username)); + } + + if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) { + return collection.filter(member => assignees.includes(member.username)); + } + + return collection; + }, + }, + + [GfmAutocompleteType.MergeRequests]: { + config: { + trigger: '!', + lookup: value => value.iid + value.title, + menuItemTemplate: ({ original }) => + `<small>${original.reference || original.iid}</small> ${escape(original.title)}`, + selectTemplate: ({ original }) => original.reference || `!${original.iid}`, + }, + }, + + [GfmAutocompleteType.Milestones]: { + config: { + trigger: '%', + lookup: 'title', + menuItemTemplate: ({ original }) => escape(original.title), + selectTemplate: ({ original }) => `%"${escape(original.title)}"`, + }, + }, + + [GfmAutocompleteType.Snippets]: { + config: { + trigger: '$', + fillAttr: 'id', + lookup: value => value.id + value.title, + menuItemTemplate: ({ original }) => `<small>${original.id}</small> ${escape(original.title)}`, + }, + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue deleted file mode 100644 index dde7e3ebe13..00000000000 --- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue +++ /dev/null @@ -1,238 +0,0 @@ -<script> -import { escape, last } from 'lodash'; -import Tribute from 'tributejs'; -import axios from '~/lib/utils/axios_utils'; -import { spriteIcon } from '~/lib/utils/common_utils'; -import SidebarMediator from '~/sidebar/sidebar_mediator'; - -const AutoComplete = { - Issues: 'issues', - Labels: 'labels', - Members: 'members', - MergeRequests: 'mergeRequests', - Milestones: 'milestones', - Snippets: 'snippets', -}; - -const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings - -function doesCurrentLineStartWith(searchString, fullText, selectionStart) { - const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length; - const currentLine = fullText.split('\n')[currentLineNumber - 1]; - return currentLine.startsWith(searchString); -} - -const autoCompleteMap = { - [AutoComplete.Issues]: { - filterValues() { - return this[AutoComplete.Issues]; - }, - menuItemTemplate({ original }) { - return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`; - }, - }, - [AutoComplete.Labels]: { - filterValues() { - const fullText = this.$slots.default?.[0]?.elm?.value; - const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart; - - if (doesCurrentLineStartWith('/label', fullText, selectionStart)) { - return this.labels.filter(label => !label.set); - } - - if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) { - return this.labels.filter(label => label.set); - } - - return this.labels; - }, - menuItemTemplate({ original }) { - return ` - <span class="dropdown-label-box" style="background: ${escape(original.color)};"></span> - ${escape(original.title)}`; - }, - }, - [AutoComplete.Members]: { - filterValues() { - const fullText = this.$slots.default?.[0]?.elm?.value; - const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart; - - // Need to check whether sidebar store assignees has been updated - // in the case where the assignees AJAX response comes after the user does @ autocomplete - const isAssigneesLengthSame = - this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length; - - if (!this.assignees || !isAssigneesLengthSame) { - this.assignees = - SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || []; - } - - if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) { - return this.members.filter(member => !this.assignees.includes(member.username)); - } - - if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) { - return this.members.filter(member => this.assignees.includes(member.username)); - } - - return this.members; - }, - menuItemTemplate({ original }) { - const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0'; - const noAvatarClasses = `${commonClasses} gl-rounded-small - gl-display-flex gl-align-items-center gl-justify-content-center`; - - const avatar = original.avatar_url - ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />` - : `<div class="${noAvatarClasses}" aria-hidden="true"> - ${original.username.charAt(0).toUpperCase()}</div>`; - - let displayName = original.name; - let parentGroupOrUsername = `@${original.username}`; - - if (original.type === groupType) { - const splitName = original.name.split(' / '); - displayName = splitName.pop(); - parentGroupOrUsername = splitName.pop(); - } - - const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : ''; - - const disabledMentionsIcon = original.mentionsDisabled - ? spriteIcon('notifications-off', 's16 gl-ml-3') - : ''; - - return ` - <div class="gl-display-flex gl-align-items-center"> - ${avatar} - <div class="gl-font-sm gl-line-height-normal gl-ml-3"> - <div>${escape(displayName)}${count}</div> - <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div> - </div> - ${disabledMentionsIcon} - </div> - `; - }, - }, - [AutoComplete.MergeRequests]: { - filterValues() { - return this[AutoComplete.MergeRequests]; - }, - menuItemTemplate({ original }) { - return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`; - }, - }, - [AutoComplete.Milestones]: { - filterValues() { - return this[AutoComplete.Milestones]; - }, - menuItemTemplate({ original }) { - return escape(original.title); - }, - }, - [AutoComplete.Snippets]: { - filterValues() { - return this[AutoComplete.Snippets]; - }, - menuItemTemplate({ original }) { - return `<small>${original.id}</small> ${escape(original.title)}`; - }, - }, -}; - -export default { - name: 'GlMentions', - props: { - dataSources: { - type: Object, - required: false, - default: () => gl.GfmAutoComplete?.dataSources || {}, - }, - }, - mounted() { - const NON_WORD_OR_INTEGER = /\W|^\d+$/; - - this.tribute = new Tribute({ - collection: [ - { - trigger: '#', - lookup: value => value.iid + value.title, - menuItemTemplate: autoCompleteMap[AutoComplete.Issues].menuItemTemplate, - selectTemplate: ({ original }) => original.reference || `#${original.iid}`, - values: this.getValues(AutoComplete.Issues), - }, - { - trigger: '@', - fillAttr: 'username', - lookup: value => - value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username, - menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate, - values: this.getValues(AutoComplete.Members), - }, - { - trigger: '~', - lookup: 'title', - menuItemTemplate: autoCompleteMap[AutoComplete.Labels].menuItemTemplate, - selectTemplate: ({ original }) => - NON_WORD_OR_INTEGER.test(original.title) - ? `~"${escape(original.title)}"` - : `~${escape(original.title)}`, - values: this.getValues(AutoComplete.Labels), - }, - { - trigger: '!', - lookup: value => value.iid + value.title, - menuItemTemplate: autoCompleteMap[AutoComplete.MergeRequests].menuItemTemplate, - selectTemplate: ({ original }) => original.reference || `!${original.iid}`, - values: this.getValues(AutoComplete.MergeRequests), - }, - { - trigger: '%', - lookup: 'title', - menuItemTemplate: autoCompleteMap[AutoComplete.Milestones].menuItemTemplate, - selectTemplate: ({ original }) => `%"${escape(original.title)}"`, - values: this.getValues(AutoComplete.Milestones), - }, - { - trigger: '$', - fillAttr: 'id', - lookup: value => value.id + value.title, - menuItemTemplate: autoCompleteMap[AutoComplete.Snippets].menuItemTemplate, - values: this.getValues(AutoComplete.Snippets), - }, - ], - }); - - const input = this.$slots.default?.[0]?.elm; - this.tribute.attach(input); - }, - beforeDestroy() { - const input = this.$slots.default?.[0]?.elm; - this.tribute.detach(input); - }, - methods: { - getValues(autoCompleteType) { - return (inputText, processValues) => { - if (this[autoCompleteType]) { - const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this); - processValues(filteredValues); - } else if (this.dataSources[autoCompleteType]) { - axios - .get(this.dataSources[autoCompleteType]) - .then(response => { - this[autoCompleteType] = response.data; - const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this); - processValues(filteredValues); - }) - .catch(() => {}); - } else { - processValues([]); - } - }; - }, - }, - render(createElement) { - return createElement('div', this.$slots.default); - }, -}; -</script> diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js index 02f28da8bb0..61ab2a698ce 100644 --- a/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js +++ b/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js @@ -1,5 +1,3 @@ export function pixeliseValue(val) { return val ? `${val}px` : ''; } - -export default {}; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 9cfba85e0d8..0d703545073 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -10,14 +10,14 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import GLForm from '~/gl_form'; import MarkdownHeader from './header.vue'; import MarkdownToolbar from './toolbar.vue'; -import GlMentions from '~/vue_shared/components/gl_mentions.vue'; +import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import axios from '~/lib/utils/axios_utils'; export default { components: { - GlMentions, + GfmAutocomplete, MarkdownHeader, MarkdownToolbar, GlIcon, @@ -246,9 +246,9 @@ export default { /> <div v-show="!previewMarkdown" class="md-write-holder"> <div class="zen-backdrop"> - <gl-mentions v-if="glFeatures.tributeAutocomplete"> + <gfm-autocomplete v-if="glFeatures.tributeAutocomplete"> <slot name="textarea"></slot> - </gl-mentions> + </gfm-autocomplete> <slot v-else name="textarea"></slot> <a class="zen-control zen-control-leave js-zen-leave gl-text-gray-500" diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue deleted file mode 100644 index 10078d5cd64..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue +++ /dev/null @@ -1,59 +0,0 @@ -<script> -import ActionButtonGroup from './action_button_group.vue'; -import RemoveMemberButton from './remove_member_button.vue'; -import ApproveAccessRequestButton from './approve_access_request_button.vue'; -import { s__, sprintf } from '~/locale'; - -export default { - name: 'AccessRequestActionButtons', - components: { ActionButtonGroup, RemoveMemberButton, ApproveAccessRequestButton }, - props: { - member: { - type: Object, - required: true, - }, - permissions: { - type: Object, - required: true, - }, - isCurrentUser: { - type: Boolean, - required: true, - }, - }, - computed: { - message() { - const { user, source } = this.member; - - if (this.isCurrentUser) { - return sprintf( - s__('Members|Are you sure you want to withdraw your access request for "%{source}"'), - { source: source.name }, - ); - } - - return sprintf( - s__('Members|Are you sure you want to deny %{usersName}\'s request to join "%{source}"'), - { usersName: user.name, source: source.name }, - ); - }, - }, -}; -</script> - -<template> - <action-button-group> - <div v-if="permissions.canUpdate" class="gl-px-1"> - <approve-access-request-button :member-id="member.id" /> - </div> - <div v-if="permissions.canRemove" class="gl-px-1"> - <remove-member-button - :member-id="member.id" - :message="message" - :title="s__('Member|Deny access')" - :is-access-request="true" - icon="close" - /> - </div> - </action-button-group> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue deleted file mode 100644 index 8356fdb60b1..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue +++ /dev/null @@ -1,11 +0,0 @@ -<script> -export default { - name: 'ActionButtonGroup', -}; -</script> - -<template> - <div class="gl-display-flex gl-flex-align-items-center gl-justify-content-end gl-mx-n1"> - <slot></slot> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue deleted file mode 100644 index e8a53ff173d..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue +++ /dev/null @@ -1,42 +0,0 @@ -<script> -import { mapState } from 'vuex'; -import { GlButton, GlForm, GlTooltipDirective } from '@gitlab/ui'; -import csrf from '~/lib/utils/csrf'; -import { __ } from '~/locale'; - -export default { - name: 'ApproveAccessRequestButton', - csrf, - title: __('Grant access'), - components: { GlButton, GlForm }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - memberId: { - type: Number, - required: true, - }, - }, - computed: { - ...mapState(['memberPath']), - approvePath() { - return this.memberPath.replace(/:id$/, `${this.memberId}/approve_access_request`); - }, - }, -}; -</script> - -<template> - <gl-form :action="approvePath" method="post"> - <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> - <gl-button - v-gl-tooltip.hover - :title="$options.title" - :aria-label="$options.title" - icon="check" - variant="success" - type="submit" - /> - </gl-form> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue deleted file mode 100644 index 2aebfe80db5..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue +++ /dev/null @@ -1,27 +0,0 @@ -<script> -import ActionButtonGroup from './action_button_group.vue'; -import RemoveGroupLinkButton from './remove_group_link_button.vue'; - -export default { - name: 'GroupActionButtons', - components: { ActionButtonGroup, RemoveGroupLinkButton }, - props: { - member: { - type: Object, - required: true, - }, - permissions: { - type: Object, - required: true, - }, - }, -}; -</script> - -<template> - <action-button-group> - <div v-if="permissions.canRemove" class="gl-px-1"> - <remove-group-link-button :group-link="member" /> - </div> - </action-button-group> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue deleted file mode 100644 index 2b0a75640e2..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue +++ /dev/null @@ -1,48 +0,0 @@ -<script> -import ActionButtonGroup from './action_button_group.vue'; -import RemoveMemberButton from './remove_member_button.vue'; -import ResendInviteButton from './resend_invite_button.vue'; -import { s__, sprintf } from '~/locale'; - -export default { - name: 'InviteActionButtons', - components: { ActionButtonGroup, RemoveMemberButton, ResendInviteButton }, - props: { - member: { - type: Object, - required: true, - }, - permissions: { - type: Object, - required: true, - }, - }, - computed: { - message() { - const { invite, source } = this.member; - - return sprintf( - s__( - 'Members|Are you sure you want to revoke the invitation for %{inviteEmail} to join "%{source}"', - ), - { inviteEmail: invite.email, source: source.name }, - ); - }, - }, -}; -</script> - -<template> - <action-button-group> - <div v-if="permissions.canResend" class="gl-px-1"> - <resend-invite-button :member-id="member.id" /> - </div> - <div v-if="permissions.canRemove" class="gl-px-1"> - <remove-member-button - :member-id="member.id" - :message="message" - :title="s__('Member|Revoke invite')" - /> - </div> - </action-button-group> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue deleted file mode 100644 index d9976e7181c..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue +++ /dev/null @@ -1,40 +0,0 @@ -<script> -import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; -import LeaveModal from '../modals/leave_modal.vue'; -import { LEAVE_MODAL_ID } from '../constants'; - -export default { - name: 'LeaveButton', - title: __('Leave'), - modalId: LEAVE_MODAL_ID, - components: { - GlButton, - LeaveModal, - }, - directives: { - GlModal: GlModalDirective, - GlTooltip: GlTooltipDirective, - }, - props: { - member: { - type: Object, - required: true, - }, - }, -}; -</script> - -<template> - <div> - <gl-button - v-gl-tooltip.hover - v-gl-modal="$options.modalId" - :title="$options.title" - :aria-label="$options.title" - icon="leave" - variant="danger" - /> - <leave-modal :member="member" /> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue deleted file mode 100644 index 9d89cb40676..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue +++ /dev/null @@ -1,36 +0,0 @@ -<script> -import { mapActions } from 'vuex'; -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; - -export default { - name: 'RemoveGroupLinkButton', - i18n: { - buttonTitle: s__('Members|Remove group'), - }, - components: { GlButton }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - groupLink: { - type: Object, - required: true, - }, - }, - methods: { - ...mapActions(['showRemoveGroupLinkModal']), - }, -}; -</script> - -<template> - <gl-button - v-gl-tooltip.hover - variant="danger" - :title="$options.i18n.buttonTitle" - :aria-label="$options.i18n.buttonTitle" - icon="remove" - @click="showRemoveGroupLinkModal(groupLink)" - /> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue deleted file mode 100644 index b0b7ff4ce9a..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue +++ /dev/null @@ -1,57 +0,0 @@ -<script> -import { mapState } from 'vuex'; -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; - -export default { - name: 'RemoveMemberButton', - components: { GlButton }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - memberId: { - type: Number, - required: true, - }, - message: { - type: String, - required: true, - }, - title: { - type: String, - required: true, - }, - icon: { - type: String, - required: false, - default: 'remove', - }, - isAccessRequest: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - ...mapState(['memberPath']), - computedMemberPath() { - return this.memberPath.replace(':id', this.memberId); - }, - }, -}; -</script> - -<template> - <gl-button - v-gl-tooltip.hover - class="js-remove-member-button" - variant="danger" - :title="title" - :aria-label="title" - :icon="icon" - :data-member-path="computedMemberPath" - :data-is-access-request="isAccessRequest" - :data-message="message" - data-qa-selector="delete_member_button" - /> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue deleted file mode 100644 index 1cc3fd17e98..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue +++ /dev/null @@ -1,41 +0,0 @@ -<script> -import { mapState } from 'vuex'; -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import csrf from '~/lib/utils/csrf'; -import { __ } from '~/locale'; - -export default { - name: 'ResendInviteButton', - csrf, - title: __('Resend invite'), - components: { GlButton }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - memberId: { - type: Number, - required: true, - }, - }, - computed: { - ...mapState(['memberPath']), - resendPath() { - return this.memberPath.replace(/:id$/, `${this.memberId}/resend_invite`); - }, - }, -}; -</script> - -<template> - <form :action="resendPath" method="post"> - <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> - <gl-button - v-gl-tooltip.hover - :title="$options.title" - :aria-label="$options.title" - icon="paper-airplane" - type="submit" - /> - </form> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue deleted file mode 100644 index 484dbb8fef5..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue +++ /dev/null @@ -1,70 +0,0 @@ -<script> -import ActionButtonGroup from './action_button_group.vue'; -import RemoveMemberButton from './remove_member_button.vue'; -import LeaveButton from './leave_button.vue'; -import { s__, sprintf } from '~/locale'; - -export default { - name: 'UserActionButtons', - components: { - ActionButtonGroup, - RemoveMemberButton, - LeaveButton, - LdapOverrideButton: () => - import('ee_component/vue_shared/components/members/ldap/ldap_override_button.vue'), - }, - props: { - member: { - type: Object, - required: true, - }, - isCurrentUser: { - type: Boolean, - required: true, - }, - permissions: { - type: Object, - required: true, - }, - }, - computed: { - message() { - const { user, source } = this.member; - - if (user) { - return sprintf( - s__('Members|Are you sure you want to remove %{usersName} from "%{source}"'), - { - usersName: user.name, - source: source.name, - }, - ); - } - - return sprintf( - s__('Members|Are you sure you want to remove this orphaned member from "%{source}"'), - { - source: source.name, - }, - ); - }, - }, -}; -</script> - -<template> - <action-button-group> - <div v-if="permissions.canRemove" class="gl-px-1"> - <leave-button v-if="isCurrentUser" :member="member" /> - <remove-member-button - v-else - :member-id="member.id" - :message="message" - :title="s__('Member|Remove member')" - /> - </div> - <div v-else-if="permissions.canOverride && !member.isOverridden" class="gl-px-1"> - <ldap-override-button :member="member" /> - </div> - </action-button-group> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue deleted file mode 100644 index 12b748f9ab6..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue +++ /dev/null @@ -1,34 +0,0 @@ -<script> -import { GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui'; -import { AVATAR_SIZE } from '../constants'; - -export default { - name: 'GroupAvatar', - avatarSize: AVATAR_SIZE, - components: { GlAvatarLink, GlAvatarLabeled }, - props: { - member: { - type: Object, - required: true, - }, - }, - computed: { - group() { - return this.member.sharedWithGroup; - }, - }, -}; -</script> - -<template> - <gl-avatar-link :href="group.webUrl"> - <gl-avatar-labeled - :label="group.fullName" - :src="group.avatarUrl" - :alt="group.fullName" - :size="$options.avatarSize" - :entity-name="group.name" - :entity-id="group.id" - /> - </gl-avatar-link> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue deleted file mode 100644 index 28654a60860..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue +++ /dev/null @@ -1,32 +0,0 @@ -<script> -import { GlAvatarLabeled } from '@gitlab/ui'; -import { AVATAR_SIZE } from '../constants'; - -export default { - name: 'InviteAvatar', - avatarSize: AVATAR_SIZE, - components: { GlAvatarLabeled }, - props: { - member: { - type: Object, - required: true, - }, - }, - computed: { - invite() { - return this.member.invite; - }, - }, -}; -</script> - -<template> - <gl-avatar-labeled - :label="invite.email" - :src="invite.avatarUrl" - :alt="invite.email" - :size="$options.avatarSize" - :entity-name="invite.email" - :entity-id="member.id" - /> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue deleted file mode 100644 index e5e7cdf149c..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue +++ /dev/null @@ -1,91 +0,0 @@ -<script> -import { - GlAvatarLink, - GlAvatarLabeled, - GlBadge, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; -import { generateBadges } from 'ee_else_ce/vue_shared/components/members/utils'; -import { __ } from '~/locale'; -import { AVATAR_SIZE } from '../constants'; -import { glEmojiTag } from '~/emoji'; - -export default { - name: 'UserAvatar', - avatarSize: AVATAR_SIZE, - orphanedUserLabel: __('Orphaned member'), - safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, - components: { - GlAvatarLink, - GlAvatarLabeled, - GlBadge, - }, - directives: { - SafeHtml, - }, - props: { - member: { - type: Object, - required: true, - }, - isCurrentUser: { - type: Boolean, - required: true, - }, - }, - computed: { - user() { - return this.member.user; - }, - badges() { - return generateBadges(this.member, this.isCurrentUser).filter(badge => badge.show); - }, - statusEmoji() { - return this.user?.status?.emoji; - }, - }, - methods: { - glEmojiTag, - }, -}; -</script> - -<template> - <gl-avatar-link - v-if="user" - class="js-user-link" - :href="user.webUrl" - :data-user-id="user.id" - :data-username="user.username" - > - <gl-avatar-labeled - :label="user.name" - :sub-label="`@${user.username}`" - :src="user.avatarUrl" - :alt="user.name" - :size="$options.avatarSize" - :entity-name="user.name" - :entity-id="user.id" - > - <template #meta> - <div v-if="statusEmoji" class="gl-p-1"> - <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag(statusEmoji)"></span> - </div> - <div v-for="badge in badges" :key="badge.text" class="gl-p-1"> - <gl-badge size="sm" :variant="badge.variant"> - {{ badge.text }} - </gl-badge> - </div> - </template> - </gl-avatar-labeled> - </gl-avatar-link> - - <gl-avatar-labeled - v-else - :label="$options.orphanedUserLabel" - :alt="$options.orphanedUserLabel" - :size="$options.avatarSize" - :entity-name="$options.orphanedUserLabel" - :entity-id="member.id" - /> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/constants.js b/app/assets/javascripts/vue_shared/components/members/constants.js deleted file mode 100644 index 5885420a122..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/constants.js +++ /dev/null @@ -1,71 +0,0 @@ -import { __ } from '~/locale'; - -export const FIELDS = [ - { - key: 'account', - label: __('Account'), - }, - { - key: 'source', - label: __('Source'), - thClass: 'col-meta', - tdClass: 'col-meta', - }, - { - key: 'granted', - label: __('Access granted'), - thClass: 'col-meta', - tdClass: 'col-meta', - }, - { - key: 'invited', - label: __('Invited'), - thClass: 'col-meta', - tdClass: 'col-meta', - }, - { - key: 'requested', - label: __('Requested'), - thClass: 'col-meta', - tdClass: 'col-meta', - }, - { - key: 'expires', - label: __('Access expires'), - thClass: 'col-meta', - tdClass: 'col-meta', - }, - { - key: 'maxRole', - label: __('Max role'), - thClass: 'col-max-role', - tdClass: 'col-max-role', - }, - { - key: 'expiration', - label: __('Expiration'), - thClass: 'col-expiration', - tdClass: 'col-expiration', - }, - { - key: 'actions', - thClass: 'col-actions', - tdClass: 'col-actions', - showFunction: 'showActionsField', - }, -]; - -export const AVATAR_SIZE = 48; - -export const MEMBER_TYPES = { - user: 'user', - group: 'group', - invite: 'invite', - accessRequest: 'accessRequest', -}; - -export const DAYS_TO_EXPIRE_SOON = 7; - -export const LEAVE_MODAL_ID = 'member-leave-modal'; - -export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id'; diff --git a/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue b/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue deleted file mode 100644 index 9a2ce0d4931..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue +++ /dev/null @@ -1,70 +0,0 @@ -<script> -import { mapState } from 'vuex'; -import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import csrf from '~/lib/utils/csrf'; -import { __, s__, sprintf } from '~/locale'; -import { LEAVE_MODAL_ID } from '../constants'; - -export default { - name: 'LeaveModal', - actionCancel: { - text: __('Cancel'), - }, - actionPrimary: { - text: __('Leave'), - attributes: { - variant: 'danger', - }, - }, - csrf, - modalId: LEAVE_MODAL_ID, - modalContent: s__('Members|Are you sure you want to leave "%{source}"?'), - components: { GlModal, GlForm, GlSprintf }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - member: { - type: Object, - required: true, - }, - }, - computed: { - ...mapState(['memberPath']), - leavePath() { - return this.memberPath.replace(/:id$/, 'leave'); - }, - modalTitle() { - return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.name }); - }, - }, - methods: { - handlePrimary() { - this.$refs.form.$el.submit(); - }, - }, -}; -</script> - -<template> - <gl-modal - v-bind="$attrs" - :modal-id="$options.modalId" - :title="modalTitle" - :action-primary="$options.actionPrimary" - :action-cancel="$options.actionCancel" - size="sm" - @primary="handlePrimary" - > - <gl-form ref="form" :action="leavePath" method="post"> - <p> - <gl-sprintf :message="$options.modalContent"> - <template #source>{{ member.source.name }}</template> - </gl-sprintf> - </p> - - <input type="hidden" name="_method" value="delete" /> - <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> - </gl-form> - </gl-modal> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue b/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue deleted file mode 100644 index e8890717724..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue +++ /dev/null @@ -1,69 +0,0 @@ -<script> -import { mapState, mapActions } from 'vuex'; -import { GlModal, GlSprintf, GlForm } from '@gitlab/ui'; -import csrf from '~/lib/utils/csrf'; -import { __, s__, sprintf } from '~/locale'; -import { REMOVE_GROUP_LINK_MODAL_ID } from '../constants'; - -export default { - name: 'RemoveGroupLinkModal', - actionCancel: { - text: __('Cancel'), - }, - actionPrimary: { - text: s__('Members|Remove group'), - attributes: { - variant: 'danger', - }, - }, - csrf, - i18n: { - modalBody: s__('Members|Are you sure you want to remove "%{groupName}"?'), - }, - modalId: REMOVE_GROUP_LINK_MODAL_ID, - components: { GlModal, GlSprintf, GlForm }, - computed: { - ...mapState(['memberPath', 'groupLinkToRemove', 'removeGroupLinkModalVisible']), - groupLinkPath() { - return this.memberPath.replace(/:id$/, this.groupLinkToRemove?.id); - }, - groupName() { - return this.groupLinkToRemove?.sharedWithGroup.fullName; - }, - modalTitle() { - return sprintf(s__('Members|Remove "%{groupName}"'), { groupName: this.groupName }); - }, - }, - methods: { - ...mapActions(['hideRemoveGroupLinkModal']), - handlePrimary() { - this.$refs.form.$el.submit(); - }, - }, -}; -</script> - -<template> - <gl-modal - v-bind="$attrs" - :modal-id="$options.modalId" - :visible="removeGroupLinkModalVisible" - :title="modalTitle" - :action-primary="$options.actionPrimary" - :action-cancel="$options.actionCancel" - size="sm" - @primary="handlePrimary" - @hide="hideRemoveGroupLinkModal" - > - <gl-form ref="form" :action="groupLinkPath" method="post"> - <p> - <gl-sprintf :message="$options.i18n.modalBody"> - <template #groupName>{{ groupName }}</template> - </gl-sprintf> - </p> - - <input type="hidden" name="_method" value="delete" /> - <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> - </gl-form> - </gl-modal> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/created_at.vue b/app/assets/javascripts/vue_shared/components/members/table/created_at.vue deleted file mode 100644 index 0bad70894f9..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/table/created_at.vue +++ /dev/null @@ -1,40 +0,0 @@ -<script> -import { GlSprintf } from '@gitlab/ui'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; - -export default { - name: 'CreatedAt', - components: { GlSprintf, TimeAgoTooltip }, - props: { - date: { - type: String, - required: false, - default: null, - }, - createdBy: { - type: Object, - required: false, - default: null, - }, - }, - computed: { - showCreatedBy() { - return this.createdBy?.name && this.createdBy?.webUrl; - }, - }, -}; -</script> - -<template> - <span> - <gl-sprintf v-if="showCreatedBy" :message="s__('Members|%{time} by %{user}')"> - <template #time> - <time-ago-tooltip :time="date" /> - </template> - <template #user> - <a :href="createdBy.webUrl">{{ createdBy.name }}</a> - </template> - </gl-sprintf> - <time-ago-tooltip v-else :time="date" /> - </span> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue b/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue deleted file mode 100644 index 0a8af81c1d1..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue +++ /dev/null @@ -1,99 +0,0 @@ -<script> -import { GlDatepicker } from '@gitlab/ui'; -import { mapActions } from 'vuex'; -import { getDateInFuture } from '~/lib/utils/datetime_utility'; -import { s__ } from '~/locale'; - -export default { - name: 'ExpirationDatepicker', - components: { GlDatepicker }, - props: { - member: { - type: Object, - required: true, - }, - permissions: { - type: Object, - required: true, - }, - }, - data() { - return { - selectedDate: null, - busy: false, - }; - }, - computed: { - minDate() { - // Members expire at the beginning of the day. - // The first selectable day should be tomorrow. - const today = new Date(); - const beginningOfToday = new Date(today.setHours(0, 0, 0, 0)); - - return getDateInFuture(beginningOfToday, 1); - }, - disabled() { - return ( - this.busy || - !this.permissions.canUpdate || - (this.permissions.canOverride && !this.member.isOverridden) - ); - }, - }, - mounted() { - if (this.member.expiresAt) { - this.selectedDate = new Date(this.member.expiresAt); - } - }, - methods: { - ...mapActions(['updateMemberExpiration']), - handleInput(date) { - this.busy = true; - this.updateMemberExpiration({ - memberId: this.member.id, - expiresAt: date, - }) - .then(() => { - this.$toast.show(s__('Members|Expiration date updated successfully.')); - this.busy = false; - }) - .catch(() => { - this.busy = false; - }); - }, - handleClear() { - this.busy = true; - - this.updateMemberExpiration({ - memberId: this.member.id, - expiresAt: null, - }) - .then(() => { - this.$toast.show(s__('Members|Expiration date removed successfully.')); - this.selectedDate = null; - this.busy = false; - }) - .catch(() => { - this.busy = false; - }); - }, - }, -}; -</script> - -<template> - <!-- `:target="null"` allows the datepicker to be opened on focus --> - <!-- `:container="null"` renders the datepicker in the body to prevent conflicting CSS table styles --> - <gl-datepicker - v-model="selectedDate" - class="gl-max-w-full" - show-clear-button - :target="null" - :container="null" - :min-date="minDate" - :placeholder="__('Expiration date')" - :disabled="disabled" - @input="handleInput" - @clear="handleClear" - /> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue b/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue deleted file mode 100644 index de65e3fb10f..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue +++ /dev/null @@ -1,66 +0,0 @@ -<script> -import { GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import { - approximateDuration, - differenceInSeconds, - formatDate, - getDayDifference, -} from '~/lib/utils/datetime_utility'; -import { DAYS_TO_EXPIRE_SOON } from '../constants'; - -export default { - name: 'ExpiresAt', - components: { GlSprintf }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - date: { - type: String, - required: false, - default: null, - }, - }, - computed: { - noExpirationSet() { - return this.date === null; - }, - parsed() { - return new Date(this.date); - }, - differenceInSeconds() { - return differenceInSeconds(new Date(), this.parsed); - }, - isExpired() { - return this.differenceInSeconds <= 0; - }, - inWords() { - return approximateDuration(this.differenceInSeconds); - }, - formatted() { - return formatDate(this.parsed); - }, - expiresSoon() { - return getDayDifference(new Date(), this.parsed) < DAYS_TO_EXPIRE_SOON; - }, - cssClass() { - return { - 'gl-text-red-500': this.isExpired, - 'gl-text-orange-500': this.expiresSoon, - }; - }, - }, -}; -</script> - -<template> - <span v-if="noExpirationSet">{{ s__('Members|No expiration set') }}</span> - <span v-else v-gl-tooltip.hover :title="formatted" :class="cssClass"> - <template v-if="isExpired">{{ s__('Members|Expired') }}</template> - <gl-sprintf v-else :message="s__('Members|in %{time}')"> - <template #time> - {{ inWords }} - </template> - </gl-sprintf> - </span> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue deleted file mode 100644 index 320d8c99223..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue +++ /dev/null @@ -1,57 +0,0 @@ -<script> -import UserActionButtons from '../action_buttons/user_action_buttons.vue'; -import GroupActionButtons from '../action_buttons/group_action_buttons.vue'; -import InviteActionButtons from '../action_buttons/invite_action_buttons.vue'; -import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue'; -import { MEMBER_TYPES } from '../constants'; - -export default { - name: 'MemberActionButtons', - components: { - UserActionButtons, - GroupActionButtons, - InviteActionButtons, - AccessRequestActionButtons, - }, - props: { - member: { - type: Object, - required: true, - }, - memberType: { - type: String, - required: true, - }, - permissions: { - type: Object, - required: true, - }, - isCurrentUser: { - type: Boolean, - required: true, - }, - }, - computed: { - actionButtonComponent() { - const dictionary = { - [MEMBER_TYPES.user]: 'user-action-buttons', - [MEMBER_TYPES.group]: 'group-action-buttons', - [MEMBER_TYPES.invite]: 'invite-action-buttons', - [MEMBER_TYPES.accessRequest]: 'access-request-action-buttons', - }; - - return dictionary[this.memberType]; - }, - }, -}; -</script> - -<template> - <component - :is="actionButtonComponent" - v-if="actionButtonComponent" - :member="member" - :permissions="permissions" - :is-current-user="isCurrentUser" - /> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue b/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue deleted file mode 100644 index a1f98d4008a..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue +++ /dev/null @@ -1,35 +0,0 @@ -<script> -import { kebabCase } from 'lodash'; -import UserAvatar from '../avatars/user_avatar.vue'; -import InviteAvatar from '../avatars/invite_avatar.vue'; -import GroupAvatar from '../avatars/group_avatar.vue'; - -export default { - name: 'MemberAvatar', - components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar }, - props: { - memberType: { - type: String, - required: true, - }, - isCurrentUser: { - type: Boolean, - required: true, - }, - member: { - type: Object, - required: true, - }, - }, - computed: { - avatarComponent() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `${kebabCase(this.memberType)}-avatar`; - }, - }, -}; -</script> - -<template> - <component :is="avatarComponent" :member="member" :is-current-user="isCurrentUser" /> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_source.vue b/app/assets/javascripts/vue_shared/components/members/table/member_source.vue deleted file mode 100644 index 030d72c3420..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/table/member_source.vue +++ /dev/null @@ -1,27 +0,0 @@ -<script> -import { GlTooltipDirective } from '@gitlab/ui'; - -export default { - name: 'MemberSource', - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - memberSource: { - type: Object, - required: true, - }, - isDirectMember: { - type: Boolean, - required: true, - }, - }, -}; -</script> - -<template> - <span v-if="isDirectMember">{{ __('Direct member') }}</span> - <a v-else v-gl-tooltip.hover :title="__('Inherited')" :href="memberSource.webUrl">{{ - memberSource.name - }}</a> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue deleted file mode 100644 index a4f67caff31..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue +++ /dev/null @@ -1,158 +0,0 @@ -<script> -import { mapState } from 'vuex'; -import { GlTable, GlBadge } from '@gitlab/ui'; -import MembersTableCell from 'ee_else_ce/vue_shared/components/members/table/members_table_cell.vue'; -import { - canOverride, - canRemove, - canResend, - canUpdate, -} from 'ee_else_ce/vue_shared/components/members/utils'; -import { FIELDS } from '../constants'; -import initUserPopovers from '~/user_popovers'; -import MemberAvatar from './member_avatar.vue'; -import MemberSource from './member_source.vue'; -import CreatedAt from './created_at.vue'; -import ExpiresAt from './expires_at.vue'; -import MemberActionButtons from './member_action_buttons.vue'; -import RoleDropdown from './role_dropdown.vue'; -import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue'; -import ExpirationDatepicker from './expiration_datepicker.vue'; - -export default { - name: 'MembersTable', - components: { - GlTable, - GlBadge, - MemberAvatar, - CreatedAt, - ExpiresAt, - MembersTableCell, - MemberSource, - MemberActionButtons, - RoleDropdown, - RemoveGroupLinkModal, - ExpirationDatepicker, - LdapOverrideConfirmationModal: () => - import( - 'ee_component/vue_shared/components/members/ldap/ldap_override_confirmation_modal.vue' - ), - }, - computed: { - ...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId', 'sourceId']), - filteredFields() { - return FIELDS.filter(field => this.tableFields.includes(field.key) && this.showField(field)); - }, - userIsLoggedIn() { - return this.currentUserId !== null; - }, - }, - mounted() { - initUserPopovers(this.$el.querySelectorAll('.js-user-link')); - }, - methods: { - showField(field) { - if (!Object.prototype.hasOwnProperty.call(field, 'showFunction')) { - return true; - } - - return this[field.showFunction](); - }, - showActionsField() { - if (!this.userIsLoggedIn) { - return false; - } - - return this.members.some(member => { - return ( - canRemove(member, this.sourceId) || - canResend(member) || - canUpdate(member, this.currentUserId, this.sourceId) || - canOverride(member) - ); - }); - }, - }, -}; -</script> - -<template> - <div> - <gl-table - v-bind="tableAttrs.table" - class="members-table" - data-testid="members-table" - head-variant="white" - stacked="lg" - :fields="filteredFields" - :items="members" - primary-key="id" - thead-class="border-bottom" - :empty-text="__('No members found')" - show-empty - :tbody-tr-attr="tableAttrs.tr" - > - <template #cell(account)="{ item: member }"> - <members-table-cell #default="{ memberType, isCurrentUser }" :member="member"> - <member-avatar - :member-type="memberType" - :is-current-user="isCurrentUser" - :member="member" - /> - </members-table-cell> - </template> - - <template #cell(source)="{ item: member }"> - <members-table-cell #default="{ isDirectMember }" :member="member"> - <member-source :is-direct-member="isDirectMember" :member-source="member.source" /> - </members-table-cell> - </template> - - <template #cell(granted)="{ item: { createdAt, createdBy } }"> - <created-at :date="createdAt" :created-by="createdBy" /> - </template> - - <template #cell(invited)="{ item: { createdAt, createdBy } }"> - <created-at :date="createdAt" :created-by="createdBy" /> - </template> - - <template #cell(requested)="{ item: { createdAt } }"> - <created-at :date="createdAt" /> - </template> - - <template #cell(expires)="{ item: { expiresAt } }"> - <expires-at :date="expiresAt" /> - </template> - - <template #cell(maxRole)="{ item: member }"> - <members-table-cell #default="{ permissions }" :member="member"> - <role-dropdown v-if="permissions.canUpdate" :permissions="permissions" :member="member" /> - <gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge> - </members-table-cell> - </template> - - <template #cell(expiration)="{ item: member }"> - <members-table-cell #default="{ permissions }" :member="member"> - <expiration-datepicker :permissions="permissions" :member="member" /> - </members-table-cell> - </template> - - <template #cell(actions)="{ item: member }"> - <members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member"> - <member-action-buttons - :member-type="memberType" - :is-current-user="isCurrentUser" - :permissions="permissions" - :member="member" - /> - </members-table-cell> - </template> - - <template #head(actions)="{ label }"> - <span data-testid="col-actions" class="gl-sr-only">{{ label }}</span> - </template> - </gl-table> - <remove-group-link-modal /> - <ldap-override-confirmation-modal /> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue deleted file mode 100644 index 11e1aef9803..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> -import { mapState } from 'vuex'; -import { MEMBER_TYPES } from '../constants'; -import { isGroup, isDirectMember, isCurrentUser, canRemove, canResend, canUpdate } from '../utils'; - -export default { - name: 'MembersTableCell', - props: { - member: { - type: Object, - required: true, - }, - }, - computed: { - ...mapState(['sourceId', 'currentUserId']), - isGroup() { - return isGroup(this.member); - }, - isInvite() { - return Boolean(this.member.invite); - }, - isAccessRequest() { - return Boolean(this.member.requestedAt); - }, - memberType() { - if (this.isGroup) { - return MEMBER_TYPES.group; - } else if (this.isInvite) { - return MEMBER_TYPES.invite; - } else if (this.isAccessRequest) { - return MEMBER_TYPES.accessRequest; - } - - return MEMBER_TYPES.user; - }, - isDirectMember() { - return isDirectMember(this.member, this.sourceId); - }, - isCurrentUser() { - return isCurrentUser(this.member, this.currentUserId); - }, - canRemove() { - return canRemove(this.member, this.sourceId); - }, - canResend() { - return canResend(this.member); - }, - canUpdate() { - return canUpdate(this.member, this.currentUserId, this.sourceId); - }, - }, - render() { - return this.$scopedSlots.default({ - memberType: this.memberType, - isDirectMember: this.isDirectMember, - isCurrentUser: this.isCurrentUser, - permissions: { - canRemove: this.canRemove, - canResend: this.canResend, - canUpdate: this.canUpdate, - }, - }); - }, -}; -</script> diff --git a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue deleted file mode 100644 index 6f6cae6072d..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue +++ /dev/null @@ -1,95 +0,0 @@ -<script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { mapActions } from 'vuex'; -import { s__ } from '~/locale'; - -export default { - name: 'RoleDropdown', - components: { - GlDropdown, - GlDropdownItem, - LdapDropdownItem: () => - import('ee_component/vue_shared/components/members/ldap/ldap_dropdown_item.vue'), - }, - props: { - member: { - type: Object, - required: true, - }, - permissions: { - type: Object, - required: true, - }, - }, - data() { - return { - isDesktop: false, - busy: false, - }; - }, - computed: { - disabled() { - return this.busy || (this.permissions.canOverride && !this.member.isOverridden); - }, - }, - mounted() { - this.isDesktop = bp.isDesktop(); - - // Bootstrap Vue and GlDropdown to not support adding attributes to the dropdown toggle - // This can be changed once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1060 is implemented - const dropdownToggle = this.$refs.glDropdown.$el.querySelector('.dropdown-toggle'); - - if (dropdownToggle) { - dropdownToggle.setAttribute('data-qa-selector', 'access_level_dropdown'); - } - }, - methods: { - ...mapActions(['updateMemberRole']), - handleSelect(value, name) { - if (value === this.member.accessLevel.integerValue) { - return; - } - - this.busy = true; - - this.updateMemberRole({ - memberId: this.member.id, - accessLevel: { integerValue: value, stringValue: name }, - }) - .then(() => { - this.$toast.show(s__('Members|Role updated successfully.')); - this.busy = false; - }) - .catch(() => { - this.busy = false; - }); - }, - }, -}; -</script> - -<template> - <gl-dropdown - ref="glDropdown" - :right="!isDesktop" - :text="member.accessLevel.stringValue" - :header-text="__('Change permissions')" - :disabled="disabled" - > - <gl-dropdown-item - v-for="(value, name) in member.validRoles" - :key="value" - is-check-item - :is-checked="value === member.accessLevel.integerValue" - data-qa-selector="access_level_link" - @click="handleSelect(value, name)" - > - {{ name }} - </gl-dropdown-item> - <ldap-dropdown-item - v-if="permissions.canOverride && member.isOverridden" - :member-id="member.id" - /> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/vue_shared/components/members/utils.js b/app/assets/javascripts/vue_shared/components/members/utils.js deleted file mode 100644 index 4229a62c0a7..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/utils.js +++ /dev/null @@ -1,48 +0,0 @@ -import { __ } from '~/locale'; - -export const generateBadges = (member, isCurrentUser) => [ - { - show: isCurrentUser, - text: __("It's you"), - variant: 'success', - }, - { - show: member.user?.blocked, - text: __('Blocked'), - variant: 'danger', - }, - { - show: member.user?.twoFactorEnabled, - text: __('2FA'), - variant: 'info', - }, -]; - -export const isGroup = member => { - return Boolean(member.sharedWithGroup); -}; - -export const isDirectMember = (member, sourceId) => { - return isGroup(member) || member.source?.id === sourceId; -}; - -export const isCurrentUser = (member, currentUserId) => { - return member.user?.id === currentUserId; -}; - -export const canRemove = (member, sourceId) => { - return isDirectMember(member, sourceId) && member.canRemove; -}; - -export const canResend = member => { - return Boolean(member.invite?.canResend); -}; - -export const canUpdate = (member, currentUserId, sourceId) => { - return ( - !isCurrentUser(member, currentUserId) && isDirectMember(member, sourceId) && member.canUpdate - ); -}; - -// Defined in `ee/app/assets/javascripts/vue_shared/components/members/utils.js` -export const canOverride = () => false; diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue index 8104d919bf6..85481f3f7b4 100644 --- a/app/assets/javascripts/vue_shared/components/pikaday.vue +++ b/app/assets/javascripts/vue_shared/components/pikaday.vue @@ -1,10 +1,14 @@ <script> import Pikaday from 'pikaday'; +import { GlIcon } from '@gitlab/ui'; import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; export default { name: 'DatePicker', + components: { + GlIcon, + }, props: { label: { type: String, @@ -66,7 +70,7 @@ export default { <div class="dropdown open"> <button type="button" class="dropdown-menu-toggle" data-toggle="dropdown" @click="toggled"> <span class="dropdown-toggle-text"> {{ label }} </span> - <i class="fa fa-chevron-down" aria-hidden="true"> </i> + <gl-icon name="chevron-down" class="gl-absolute gl-right-3 gl-top-3 gl-text-gray-500" /> </button> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue index c90bd4da6c2..3dbf0ccdfa9 100644 --- a/app/assets/javascripts/vue_shared/components/select2_select.vue +++ b/app/assets/javascripts/vue_shared/components/select2_select.vue @@ -1,6 +1,7 @@ <script> import $ from 'jquery'; import 'select2'; +import { loadCSSFile } from '~/lib/utils/css_utils'; export default { // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 @@ -20,10 +21,14 @@ export default { }, mounted() { - $(this.$refs.dropdownInput) - .val(this.value) - .select2(this.options) - .on('change', event => this.$emit('input', event.target.value)); + loadCSSFile(gon.select2_css_path) + .then(() => { + $(this.$refs.dropdownInput) + .val(this.value) + .select2(this.options) + .on('change', event => this.$emit('input', event.target.value)); + }) + .catch(() => {}); }, beforeDestroy() { 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 2f71907f772..8ce624aa303 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 @@ -105,6 +105,11 @@ export default { required: false, default: __('Manage group labels'), }, + isEditing: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -131,6 +136,11 @@ export default { showDropdownContents(showDropdownContents) { this.setContentIsOnViewport(showDropdownContents); }, + isEditing(newVal) { + if (newVal) { + this.toggleDropdownContents(); + } + }, }, mounted() { this.setInitialState({ diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue index 579ad53e6db..b48dfa8b452 100644 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue @@ -1,6 +1,7 @@ <script> import { isFunction } from 'lodash'; import tooltip from '../directives/tooltip'; +import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; export default { directives: { @@ -49,7 +50,7 @@ export default { }, updateTooltip() { const target = this.selectTarget(); - this.showTooltip = Boolean(target && target.scrollWidth > target.offsetWidth); + this.showTooltip = hasHorizontalOverflow(target); }, }, }; diff --git a/app/assets/javascripts/vue_shared/security_reports/components/constants.js b/app/assets/javascripts/vue_shared/security_reports/components/constants.js new file mode 100644 index 00000000000..9b1cbfe218b --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/constants.js @@ -0,0 +1,8 @@ +export const SEVERITY_CLASS_NAME_MAP = { + critical: 'text-danger-800', + high: 'text-danger-600', + medium: 'text-warning-400', + low: 'text-warning-200', + info: 'text-primary-400', + unknown: 'text-secondary-400', +}; diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue new file mode 100644 index 00000000000..babb9fddcf6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue @@ -0,0 +1,59 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { SEVERITY_CLASS_NAME_MAP } from './constants'; + +export default { + components: { + GlSprintf, + }, + props: { + message: { + type: Object, + required: true, + }, + }, + computed: { + shouldShowCountMessage() { + return !this.message.status && Boolean(this.message.countMessage); + }, + }, + methods: { + getSeverityClass(severity) { + return SEVERITY_CLASS_NAME_MAP[severity]; + }, + }, + slotNames: ['critical', 'high', 'other'], + spacingClasses: { + critical: 'gl-pl-4', + high: 'gl-px-2', + other: 'gl-px-2', + }, +}; +</script> + +<template> + <span> + <gl-sprintf :message="message.message"> + <template #total="{content}"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + <span v-if="shouldShowCountMessage" class="gl-font-sm"> + <gl-sprintf :message="message.countMessage"> + <template v-for="slotName in $options.slotNames" #[slotName]="{content}"> + <span :key="slotName"> + <strong + v-if="message[slotName] > 0" + :class="[getSeverityClass(slotName), $options.spacingClasses[slotName]]" + > + {{ content }} + </strong> + <span v-else :class="$options.spacingClasses[slotName]"> + {{ content }} + </span> + </span> + </template> + </gl-sprintf> + </span> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js index 2f87c4e7878..413b4a70b40 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -1,3 +1,9 @@ export const FEEDBACK_TYPE_DISMISSAL = 'dismissal'; export const FEEDBACK_TYPE_ISSUE = 'issue'; export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request'; + +/** + * Security scan report types, as provided by the backend. + */ +export const REPORT_TYPE_SAST = 'sast'; +export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection'; diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue index 89253cc7116..b61783ed7b0 100644 --- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue +++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue @@ -1,19 +1,28 @@ <script> +import { mapActions, mapGetters } from 'vuex'; import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ReportSection from '~/reports/components/report_section.vue'; -import { status } from '~/reports/constants'; +import { LOADING, ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/reports/constants'; import { s__ } from '~/locale'; import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; -import Flash from '~/flash'; +import createFlash from '~/flash'; import Api from '~/api'; +import SecuritySummary from './components/security_summary.vue'; +import store from './store'; +import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants'; +import { REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION } from './constants'; export default { + store, components: { GlIcon, GlLink, GlSprintf, ReportSection, + SecuritySummary, }, + mixins: [glFeatureFlagsMixin()], props: { pipelineId: { type: Number, @@ -27,25 +36,53 @@ export default { type: String, required: true, }, + sastComparisonPath: { + type: String, + required: false, + default: '', + }, + secretScanningComparisonPath: { + type: String, + required: false, + default: '', + }, }, data() { return { - hasSecurityReports: false, + availableSecurityReports: [], + canShowCounts: false, - // Error state is shown even when successfully loaded, since success + // When core_security_mr_widget_counts is not enabled, the + // error state is shown even when successfully loaded, since success // state suggests that the security scans detected no security problems, // which is not necessarily the case. A future iteration will actually // check whether problems were found and display the appropriate status. - status: status.ERROR, + status: ERROR, }; }, + computed: { + ...mapGetters(['groupedSummaryText', 'summaryStatus']), + hasSecurityReports() { + return this.availableSecurityReports.length > 0; + }, + hasSastReports() { + return this.availableSecurityReports.includes(REPORT_TYPE_SAST); + }, + hasSecretDetectionReports() { + return this.availableSecurityReports.includes(REPORT_TYPE_SECRET_DETECTION); + }, + isLoaded() { + return this.summaryStatus !== LOADING; + }, + }, created() { - this.checkHasSecurityReports(this.$options.reportTypes) - .then(hasSecurityReports => { - this.hasSecurityReports = hasSecurityReports; + this.checkAvailableSecurityReports(this.$options.reportTypes) + .then(availableSecurityReports => { + this.availableSecurityReports = Array.from(availableSecurityReports); + this.fetchCounts(); }) .catch(error => { - Flash({ + createFlash({ message: this.$options.i18n.apiError, captureError: true, error, @@ -53,7 +90,18 @@ export default { }); }, methods: { - async checkHasSecurityReports(reportTypes) { + ...mapActions(MODULE_SAST, { + setSastDiffEndpoint: 'setDiffEndpoint', + fetchSastDiff: 'fetchDiff', + }), + ...mapActions(MODULE_SECRET_DETECTION, { + setSecretDetectionDiffEndpoint: 'setDiffEndpoint', + fetchSecretDetectionDiff: 'fetchDiff', + }), + async checkAvailableSecurityReports(reportTypes) { + const reportTypesSet = new Set(reportTypes); + const availableReportTypes = new Set(); + let page = 1; while (page) { // eslint-disable-next-line no-await-in-loop @@ -62,18 +110,40 @@ export default { page, }); - const hasSecurityReports = jobs.some(({ artifacts = [] }) => - artifacts.some(({ file_type }) => reportTypes.includes(file_type)), - ); + jobs.forEach(({ artifacts = [] }) => { + artifacts.forEach(({ file_type }) => { + if (reportTypesSet.has(file_type)) { + availableReportTypes.add(file_type); + } + }); + }); - if (hasSecurityReports) { - return true; + // If we've found artifacts for all the report types, stop looking! + if (availableReportTypes.size === reportTypesSet.size) { + return availableReportTypes; } page = parseIntPagination(normalizeHeaders(headers)).nextPage; } - return false; + return availableReportTypes; + }, + fetchCounts() { + if (!this.glFeatures.coreSecurityMrWidgetCounts) { + return; + } + + if (this.sastComparisonPath && this.hasSastReports) { + this.setSastDiffEndpoint(this.sastComparisonPath); + this.fetchSastDiff(); + this.canShowCounts = true; + } + + if (this.secretScanningComparisonPath && this.hasSecretDetectionReports) { + this.setSecretDetectionDiffEndpoint(this.secretScanningComparisonPath); + this.fetchSecretDetectionDiff(); + this.canShowCounts = true; + } }, activatePipelinesTab() { if (window.mrTabs) { @@ -81,7 +151,7 @@ export default { } }, }, - reportTypes: ['sast', 'secret_detection'], + reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION], i18n: { apiError: s__( 'SecurityReports|Failed to get security report information. Please reload the page or try again later.', @@ -89,13 +159,57 @@ export default { scansHaveRun: s__( 'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', ), + downloadFromPipelineTab: s__( + 'SecurityReports|Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', + ), securityReportsHelp: s__('SecurityReports|Security reports help page link'), }, + summarySlots: [SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR], }; </script> <template> <report-section - v-if="hasSecurityReports" + v-if="canShowCounts" + :status="summaryStatus" + :has-issues="false" + class="mr-widget-border-top mr-report" + data-testid="security-mr-widget" + > + <template v-for="slot in $options.summarySlots" #[slot]> + <span :key="slot"> + <security-summary :message="groupedSummaryText" /> + + <gl-link + target="_blank" + data-testid="help" + :href="securityReportsDocsPath" + :aria-label="$options.i18n.securityReportsHelp" + > + <gl-icon name="question" /> + </gl-link> + </span> + </template> + + <template v-if="isLoaded" #sub-heading> + <span class="gl-font-sm"> + <gl-sprintf :message="$options.i18n.downloadFromPipelineTab"> + <template #link="{ content }"> + <gl-link + class="gl-font-sm" + data-testid="show-pipelines" + @click="activatePipelinesTab" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </span> + </template> + </report-section> + + <!-- TODO: Remove this section when removing core_security_mr_widget_counts + feature flag. See https://gitlab.com/gitlab-org/gitlab/-/issues/284097 --> + <report-section + v-else-if="hasSecurityReports" :status="status" :has-issues="false" class="mr-widget-border-top mr-report" diff --git a/app/assets/javascripts/vue_shared/security_reports/store/constants.js b/app/assets/javascripts/vue_shared/security_reports/store/constants.js new file mode 100644 index 00000000000..6aeab56eea2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/constants.js @@ -0,0 +1,7 @@ +/** + * Vuex module names corresponding to security scan types. These are similar to + * the snake_case report types from the backend, but should not be considered + * to be equivalent. + */ +export const MODULE_SAST = 'sast'; +export const MODULE_SECRET_DETECTION = 'secretDetection'; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/getters.js b/app/assets/javascripts/vue_shared/security_reports/store/getters.js new file mode 100644 index 00000000000..1e5a60c32fd --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/getters.js @@ -0,0 +1,66 @@ +import { s__, sprintf } from '~/locale'; +import { countVulnerabilities, groupedTextBuilder } from './utils'; +import { LOADING, ERROR, SUCCESS } from '~/reports/constants'; +import { TRANSLATION_IS_LOADING } from './messages'; + +export const summaryCounts = state => + countVulnerabilities( + state.reportTypes.reduce((acc, reportType) => { + acc.push(...state[reportType].newIssues); + return acc; + }, []), + ); + +export const groupedSummaryText = (state, getters) => { + const reportType = s__('ciReport|Security scanning'); + let status = ''; + + // All reports are loading + if (getters.areAllReportsLoading) { + return { message: sprintf(TRANSLATION_IS_LOADING, { reportType }) }; + } + + // All reports returned error + if (getters.allReportsHaveError) { + return { message: s__('ciReport|Security scanning failed loading any results') }; + } + + if (getters.areReportsLoading && getters.anyReportHasError) { + status = s__('ciReport|is loading, errors when loading results'); + } else if (getters.areReportsLoading && !getters.anyReportHasError) { + status = s__('ciReport|is loading'); + } else if (!getters.areReportsLoading && getters.anyReportHasError) { + status = s__('ciReport|: Loading resulted in an error'); + } + + const { critical, high, other } = getters.summaryCounts; + + return groupedTextBuilder({ reportType, status, critical, high, other }); +}; + +export const summaryStatus = (state, getters) => { + if (getters.areReportsLoading) { + return LOADING; + } + + if (getters.anyReportHasError || getters.anyReportHasIssues) { + return ERROR; + } + + return SUCCESS; +}; + +export const areReportsLoading = state => + state.reportTypes.some(reportType => state[reportType].isLoading); + +export const areAllReportsLoading = state => + state.reportTypes.every(reportType => state[reportType].isLoading); + +export const allReportsHaveError = state => + state.reportTypes.every(reportType => state[reportType].hasError); + +export const anyReportHasError = state => + state.reportTypes.some(reportType => state[reportType].hasError); + +export const anyReportHasIssues = state => + state.reportTypes.some(reportType => state[reportType].newIssues.length > 0); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/index.js b/app/assets/javascripts/vue_shared/security_reports/store/index.js new file mode 100644 index 00000000000..10705e04a21 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/index.js @@ -0,0 +1,16 @@ +import Vuex from 'vuex'; +import * as getters from './getters'; +import state from './state'; +import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants'; +import sast from './modules/sast'; +import secretDetection from './modules/secret_detection'; + +export default () => + new Vuex.Store({ + modules: { + [MODULE_SAST]: sast, + [MODULE_SECRET_DETECTION]: secretDetection, + }, + getters, + state, + }); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/messages.js b/app/assets/javascripts/vue_shared/security_reports/store/messages.js new file mode 100644 index 00000000000..c25e252a768 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/messages.js @@ -0,0 +1,4 @@ +import { s__ } from '~/locale'; + +export const TRANSLATION_IS_LOADING = s__('ciReport|%{reportType} is loading'); +export const TRANSLATION_HAS_ERROR = s__('ciReport|%{reportType}: Loading resulted in an error'); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/state.js b/app/assets/javascripts/vue_shared/security_reports/store/state.js new file mode 100644 index 00000000000..5dc4d1ad2fb --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/state.js @@ -0,0 +1,5 @@ +import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants'; + +export default () => ({ + reportTypes: [MODULE_SAST, MODULE_SECRET_DETECTION], +}); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js index 6e50efae741..c5e786c92b1 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js @@ -1,5 +1,7 @@ import pollUntilComplete from '~/lib/utils/poll_until_complete'; import axios from '~/lib/utils/axios_utils'; +import { __, n__, sprintf } from '~/locale'; +import { CRITICAL, HIGH } from '~/vulnerabilities/constants'; import { FEEDBACK_TYPE_DISMISSAL, FEEDBACK_TYPE_ISSUE, @@ -73,3 +75,79 @@ export const parseDiff = (diff, enrichData) => { existing: diff.existing ? diff.existing.map(enrichVulnerability) : [], }; }; + +const createCountMessage = ({ critical, high, other, total }) => { + const otherMessage = n__('%d Other', '%d Others', other); + const countMessage = __( + '%{criticalStart}%{critical} Critical%{criticalEnd} %{highStart}%{high} High%{highEnd} and %{otherStart}%{otherMessage}%{otherEnd}', + ); + return total ? sprintf(countMessage, { critical, high, otherMessage }) : ''; +}; + +const createStatusMessage = ({ reportType, status, total }) => { + const vulnMessage = n__('vulnerability', 'vulnerabilities', total); + let message; + if (status) { + message = __('%{reportType} %{status}'); + } else if (!total) { + message = __('%{reportType} detected %{totalStart}no%{totalEnd} vulnerabilities.'); + } else { + message = __( + '%{reportType} detected %{totalStart}%{total}%{totalEnd} potential %{vulnMessage}', + ); + } + return sprintf(message, { reportType, status, total, vulnMessage }); +}; + +/** + * Counts vulnerabilities. + * Returns the amount of critical, high, and other vulnerabilities. + * + * @param {Array} vulnerabilities The raw vulnerabilities to parse + * @returns {{critical: number, high: number, other: number}} + */ +export const countVulnerabilities = (vulnerabilities = []) => + vulnerabilities.reduce( + (acc, { severity }) => { + if (severity === CRITICAL) { + acc.critical += 1; + } else if (severity === HIGH) { + acc.high += 1; + } else { + acc.other += 1; + } + + return acc; + }, + { critical: 0, high: 0, other: 0 }, + ); + +/** + * Takes an object of options and returns the object with an externalized string representing + * the critical, high, and other severity vulnerabilities for a given report. + * + * The resulting string _may_ still contain sprintf-style placeholders. These + * are left in place so they can be replaced with markup, via the + * SecuritySummary component. + * @param {{reportType: string, status: string, critical: number, high: number, other: number}} options + * @returns {Object} the parameters with an externalized string + */ +export const groupedTextBuilder = ({ + reportType = '', + status = '', + critical = 0, + high = 0, + other = 0, +} = {}) => { + const total = critical + high + other; + + return { + countMessage: createCountMessage({ critical, high, other, total }), + message: createStatusMessage({ reportType, status, total }), + critical, + high, + other, + status, + total, + }; +}; |