diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-17 11:59:07 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-17 11:59:07 +0000 |
commit | 8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca (patch) | |
tree | 544930fb309b30317ae9797a9683768705d664c4 /app/assets/javascripts/vue_shared | |
parent | 4b1de649d0168371549608993deac953eb692019 (diff) | |
download | gitlab-ce-8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca.tar.gz |
Add latest changes from gitlab-org/gitlab@13-7-stable-eev13.7.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_shared')
77 files changed, 1239 insertions, 1933 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..9a6433963bc 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"> - <gl-icon aria-hidden="true" name="slight-smile" /> + <span class="reaction-control-icon reaction-control-icon-neutral"> + <gl-icon name="slight-smile" /> </span> - <span class="award-control-icon award-control-icon-positive"> - <gl-icon aria-hidden="true" name="smiley" /> + <span class="reaction-control-icon reaction-control-icon-positive"> + <gl-icon 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 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/callout.vue b/app/assets/javascripts/vue_shared/components/callout.vue deleted file mode 100644 index 56bafebf4ce..00000000000 --- a/app/assets/javascripts/vue_shared/components/callout.vue +++ /dev/null @@ -1,24 +0,0 @@ -<script> -const calloutVariants = ['danger', 'success', 'info', 'warning']; - -export default { - props: { - category: { - type: String, - required: false, - default: calloutVariants[0], - validator: value => calloutVariants.includes(value), - }, - message: { - type: String, - required: false, - default: '', - }, - }, -}; -</script> -<template> - <div :class="`bs-callout bs-callout-${category}`" role="alert" aria-live="assertive"> - {{ message }} <slot></slot> - </div> -</template> 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 1b7e51b7d02..f388a468fd2 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -20,6 +20,7 @@ import CiIcon from './ci_icon.vue'; * - Pipeline show view - header * - Job show view - header * - MR widget + * - Terraform table */ export default { diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index d775a093f5f..07bd6019b80 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -63,5 +63,7 @@ export default { }; </script> <template> - <span :class="cssClass"> <gl-icon :name="icon" :size="size" :class="cssClasses" /> </span> + <span :class="cssClass"> + <gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" /> + </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index 960551fae91..bf1361f1a6a 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -84,5 +84,8 @@ export default { :size="size" icon="copy-to-clipboard" :aria-label="__('Copy this value')" - /> + v-on="$listeners" + > + <slot></slot> + </gl-button> </template> diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue new file mode 100644 index 00000000000..6977692e30c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue @@ -0,0 +1,142 @@ +<script> +/** + * Renders a color picker input with preset colors to choose from + * + * @example + * <color-picker :label="__('Background color')" set-color="#FF0000" /> + */ +import { GlFormGroup, GlFormInput, GlFormInputGroup, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const VALID_RGB_HEX_COLOR = /^#([0-9A-F]{3}){1,2}$/i; +const PREVIEW_COLOR_DEFAULT_CLASSES = + 'gl-relative gl-w-7 gl-bg-gray-10 gl-rounded-top-left-base gl-rounded-bottom-left-base'; + +export default { + name: 'ColorPicker', + components: { + GlFormGroup, + GlFormInput, + GlFormInputGroup, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + label: { + type: String, + required: false, + default: '', + }, + setColor: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + selectedColor: this.setColor.trim() || '', + }; + }, + computed: { + description() { + return this.hasSuggestedColors + ? this.$options.i18n.fullDescription + : this.$options.i18n.shortDescription; + }, + suggestedColors() { + return gon.suggested_label_colors; + }, + previewColor() { + if (this.isValidColor) { + return { backgroundColor: this.selectedColor }; + } + + return {}; + }, + previewColorClasses() { + const borderStyle = this.isInvalidColor + ? 'gl-inset-border-1-red-500' + : 'gl-inset-border-1-gray-400'; + + return `${PREVIEW_COLOR_DEFAULT_CLASSES} ${borderStyle}`; + }, + hasSuggestedColors() { + return Object.keys(this.suggestedColors).length; + }, + isInvalidColor() { + return this.isValidColor === false; + }, + isValidColor() { + if (this.selectedColor === '') { + return null; + } + + return VALID_RGB_HEX_COLOR.test(this.selectedColor); + }, + }, + methods: { + handleColorChange(color) { + this.selectedColor = color.trim(); + + if (this.isValidColor) { + this.$emit('input', this.selectedColor); + } + }, + }, + i18n: { + fullDescription: __('Choose any color. Or you can choose one of the suggested colors below'), + shortDescription: __('Choose any color'), + invalid: __('Please enter a valid hex (#RRGGBB or #RGB) color value'), + }, +}; +</script> + +<template> + <div> + <gl-form-group + :label="label" + label-for="color-picker" + :description="description" + :invalid-feedback="this.$options.i18n.invalid" + :state="isValidColor" + :class="{ 'gl-mb-3!': hasSuggestedColors }" + > + <gl-form-input-group + id="color-picker" + :state="isValidColor" + max-length="7" + type="text" + class="gl-align-center gl-rounded-0 gl-rounded-top-right-base gl-rounded-bottom-right-base" + :value="selectedColor" + @input="handleColorChange" + > + <template #prepend> + <div :class="previewColorClasses" :style="previewColor" data-testid="color-preview"> + <gl-form-input + type="color" + class="gl-absolute gl-top-0 gl-left-0 gl-h-full! gl-p-0! gl-m-0! gl-cursor-pointer gl-opacity-0" + tabindex="-1" + :value="selectedColor" + @input="handleColorChange" + /> + </div> + </template> + </gl-form-input-group> + </gl-form-group> + + <div v-if="hasSuggestedColors" class="gl-mb-3"> + <gl-link + v-for="(name, hex) in suggestedColors" + :key="hex" + v-gl-tooltip + :title="name" + :style="{ backgroundColor: hex }" + class="gl-rounded-base gl-w-7 gl-h-7 gl-display-inline-block gl-mr-3 gl-mb-3 gl-text-decoration-none" + @click.prevent="handleColorChange(hex)" + /> + </div> + </div> +</template> 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..eb7e24734ce 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 @@ -28,6 +28,8 @@ export default { return { width: 0, height: 0, + renderedWidth: 0, + renderedHeight: 0, }; }, computed: { @@ -63,11 +65,14 @@ export default { this.height = contentImg.naturalHeight; this.$nextTick(() => { + this.renderedWidth = contentImg.clientWidth; + this.renderedHeight = contentImg.clientHeight; + this.$emit('imgLoaded', { width: this.width, height: this.height, - renderedWidth: contentImg.clientWidth, - renderedHeight: contentImg.clientHeight, + renderedWidth: this.renderedWidth, + renderedHeight: this.renderedHeight, }); }); } @@ -77,9 +82,14 @@ 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> + <img ref="contentImg" :src="path" @load="onImgLoad" /> + <slot + name="image-overlay" + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + ></slot> </div> <p v-if="renderInfo" class="image-info"> <template v-if="hasFileSize"> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index 6bb05e59f6b..67be76604a3 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -109,7 +109,7 @@ export default { </script> <template> - <div ref="markdownPreview" class="md-previewer"> + <div ref="markdownPreview" class="md-previewer" data-testid="md-previewer"> <gl-skeleton-loading v-if="isLoading" /> <div v-else class="md" v-html="previewContent"></div> </div> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index a7e6438a935..79cdf308ac5 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -219,7 +219,7 @@ export default { <span v-if="utc" class="gl-text-gray-500 gl-font-weight-bold gl-font-sm">{{ __('UTC') }}</span> - <gl-icon class="gl-dropdown-caret" name="chevron-down" aria-hidden="true" /> + <gl-icon class="gl-dropdown-caret" name="chevron-down" /> </template> <div class="d-flex justify-content-between gl-p-2"> 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/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index a2fe19f9672..e755494a668 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -106,7 +106,13 @@ export default { :a-mode="aMode" :b-mode="bMode" > - <slot slot="image-overlay" name="image-overlay"></slot> + <template #image-overlay="{ renderedWidth, renderedHeight }"> + <slot + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + name="image-overlay" + ></slot> + </template> </component> <slot></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue index 2b5b2269ec8..433aafdeb9e 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue @@ -141,7 +141,13 @@ export default { :path="newPath" @imgLoaded="onionNewImgLoaded" > - <slot slot="image-overlay" name="image-overlay"> </slot> + <template #image-overlay="{ renderedWidth, renderedHeight }"> + <slot + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + name="image-overlay" + ></slot> + </template> </image-viewer> </div> <div class="controls"> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue index 2f2618d448f..acca6ba117f 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue @@ -143,7 +143,13 @@ export default { class="frame added" @imgLoaded="swipeNewImgLoaded" > - <slot slot="image-overlay" name="image-overlay"> </slot> + <template #image-overlay="{ renderedWidth, renderedHeight }"> + <slot + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + name="image-overlay" + ></slot> + </template> </image-viewer> </div> <span diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue index 4dbfdb6d79c..97cac919b2a 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue @@ -44,7 +44,13 @@ export default { :inner-css-classes="['frame', 'added']" class="wrap w-50" > - <slot slot="image-overlay" name="image-overlay"> </slot> + <template #image-overlay="{ renderedWidth, renderedHeight }"> + <slot + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + name="image-overlay" + ></slot> + </template> </image-viewer> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue index 6f5a133b225..00033145603 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue @@ -76,7 +76,13 @@ export default { <div v-if="diffMode === $options.diffModes.replaced" class="diff-viewer"> <div class="image js-replaced-image"> <component :is="imageViewComponent" v-bind="$props"> - <slot slot="image-overlay" name="image-overlay"> </slot> + <template #image-overlay="{ renderedWidth, renderedHeight }"> + <slot + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + name="image-overlay" + ></slot> + </template> </component> </div> <div class="view-modes"> @@ -121,7 +127,13 @@ export default { }, ]" > - <slot v-if="isNew || isRenamed" slot="image-overlay" name="image-overlay"> </slot> + <template v-if="isNew || isRenamed" #image-overlay="{ renderedWidth, renderedHeight }"> + <slot + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + name="image-overlay" + ></slot> + </template> </image-viewer> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/dismissible_container.vue b/app/assets/javascripts/vue_shared/components/dismissible_container.vue index b4227bae09e..6d5fd065751 100644 --- a/app/assets/javascripts/vue_shared/components/dismissible_container.vue +++ b/app/assets/javascripts/vue_shared/components/dismissible_container.vue @@ -45,7 +45,7 @@ export default { data-testid="close" @click="dismiss" > - <gl-icon name="close" aria-hidden="true" class="gl-text-gray-500" /> + <gl-icon name="close" class="gl-text-gray-500" /> </button> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue index 48b94fdc181..edb5ffdc39c 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue @@ -44,6 +44,6 @@ export default { type="search" autocomplete="off" /> - <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" data-hidden="true" /> + <gl-icon name="search" class="dropdown-input-search" data-hidden="true" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index 386df617d47..05403b38850 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -234,7 +234,6 @@ export default { name="search" class="dropdown-input-search" :class="{ hidden: showClearInputButton }" - aria-hidden="true" /> <gl-icon name="close" 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/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 97b4ceda033..3988b3814f9 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 @@ -286,6 +286,7 @@ export default { handleFilterSubmit() { const filterTokens = uniqueTokens(this.filterValue); this.filterValue = filterTokens; + if (this.recentSearchesStorageKey) { this.recentSearchesPromise .then(() => { @@ -302,6 +303,17 @@ export default { this.blurSearchInput(); this.$emit('onFilter', this.removeQuotesEnclosure(filterTokens)); }, + historyTokenOptionTitle(historyToken) { + const tokenOption = this.tokens + .find(token => token.type === historyToken.type) + ?.options?.find(option => option.value === historyToken.value.data); + + if (!tokenOption?.title) { + return historyToken.value.data; + } + + return tokenOption.title; + }, }, }; </script> @@ -333,7 +345,7 @@ export default { <span v-if="tokenTitles[token.type]" >{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span > - <strong>{{ tokenSymbols[token.type] }}{{ token.value.data }}</strong> + <strong>{{ tokenSymbols[token.type] }}{{ historyTokenOptionTitle(token) }}</strong> </span> </template> </template> 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..1ad0ca36bf8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue @@ -0,0 +1,97 @@ +<script> +import Tribute from '@gitlab/tributejs'; +import { + GfmAutocompleteType, + tributeConfig, +} from 'ee_else_ce/vue_shared/components/gfm_autocomplete/utils'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; + +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, + loadingItemTemplate: `<span class="gl-spinner gl-vertical-align-text-bottom gl-ml-3 gl-mr-2"></span>${__( + 'Loading', + )}`, + requireLeadingSpace: true, + 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..2581888b504 --- /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/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue index 7154360611f..821ae6cec52 100644 --- a/app/assets/javascripts/vue_shared/components/help_popover.vue +++ b/app/assets/javascripts/vue_shared/components/help_popover.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { GlIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { inserted } from '~/feature_highlight/feature_highlight_helper'; import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover'; @@ -11,7 +11,7 @@ import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover export default { name: 'HelpPopover', components: { - GlIcon, + GlButton, }, props: { options: { @@ -43,7 +43,5 @@ export default { }; </script> <template> - <button type="button" class="btn btn-blank btn-transparent btn-help" tabindex="0"> - <gl-icon name="question" /> - </button> + <gl-button variant="link" icon="question" tabindex="0" /> </template> 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/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue deleted file mode 100644 index 59ce632c4a2..00000000000 --- a/app/assets/javascripts/vue_shared/components/loading_button.vue +++ /dev/null @@ -1,61 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -/* eslint-disable vue/require-default-prop */ -/* -This component will be deprecated in favor of gl-deprecated-button. -https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-button--loading-button -https://gitlab.com/gitlab-org/gitlab/issues/207412 -*/ - -export default { - components: { - GlLoadingIcon, - }, - props: { - loading: { - type: Boolean, - required: false, - default: false, - }, - disabled: { - type: Boolean, - required: false, - default: false, - }, - label: { - type: String, - required: false, - }, - containerClass: { - type: [String, Array, Object], - required: false, - default: 'btn btn-align-content', - }, - }, - methods: { - onClick(e) { - this.$emit('click', e); - }, - }, -}; -</script> - -<template> - <button :class="containerClass" :disabled="loading || disabled" type="button" @click="onClick"> - <transition name="fade-in"> - <gl-loading-icon - v-if="loading" - :inline="true" - :class="{ - 'gl-mr-2': label, - }" - class="js-loading-button-icon" - /> - </transition> - <transition name="fade-in"> - <slot> - <span v-if="label" class="js-loading-button-label"> {{ label }} </span> - </slot> - </transition> - </button> -</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 new file mode 100644 index 00000000000..b9729a3dc5c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -0,0 +1,59 @@ +<script> +import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; + +export default { + components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton }, + props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, + fileName: { + type: String, + required: true, + }, + }, + data() { + return { + message: null, + buttonText: __('Apply suggestion'), + headerText: __('Apply suggestion commit message'), + }; + }, + computed: { + placeholderText() { + return sprintf(__('Apply suggestion on %{fileName}'), { fileName: this.fileName }); + }, + }, + methods: { + onApply() { + this.$emit('apply', this.message || this.placeholderText); + }, + }, +}; +</script> + +<template> + <gl-dropdown + :text="buttonText" + :header-text="headerText" + :disabled="disabled" + boundary="window" + right + menu-class="gl-w-full! gl-pb-0!" + > + <gl-dropdown-form class="gl-m-3!"> + <gl-form-textarea v-model="message" :placeholder="placeholderText" /> + <gl-button + class="gl-w-quarter! gl-mt-3 gl-text-center! float-right" + category="secondary" + variant="success" + @click="onApply" + > + {{ __('Apply') }} + </gl-button> + </gl-dropdown-form> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 9cfba85e0d8..232a3054cd0 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, @@ -173,7 +173,7 @@ export default { members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - epics: this.enableAutocomplete, + epics: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, @@ -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/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue index c12012d8419..ad6f6e0e2e3 100644 --- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue +++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue @@ -88,7 +88,7 @@ export default { }; </script> <template> - <div class="issuable-note-warning"> + <div class="issuable-note-warning" data-testid="confidential-warning"> <gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" /> <span v-if="isLockedAndConfidential" ref="lockedAndConfidential"> 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/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue index 9eacf74bba8..fe50a459e52 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue @@ -105,6 +105,8 @@ export default { registerHTMLToMarkdownRenderer(editorApi); this.addListeners(editorApi); + + this.$emit('load', { formattedMarkdown: editorApi.getMarkdown() }); }, onOpenAddImageModal() { this.$refs.addImageModal.show(); 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/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue index 7b2802650a2..4f505b9e678 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue @@ -16,7 +16,7 @@ export default { type="button" class="dropdown-title-button dropdown-menu-close gl-ml-auto" > - <gl-icon name="close" aria-hidden="true" class="dropdown-menu-close-icon" /> + <gl-icon name="close" class="dropdown-menu-close-icon" /> </button> </div> </template> 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/directives/popover.js b/app/assets/javascripts/vue_shared/directives/popover.js deleted file mode 100644 index c913bc34c68..00000000000 --- a/app/assets/javascripts/vue_shared/directives/popover.js +++ /dev/null @@ -1,22 +0,0 @@ -import $ from 'jquery'; - -/** - * Helper to user bootstrap popover in vue.js. - * Follow docs for html attributes: https://getbootstrap.com/docs/3.3/javascript/#static-popover - * - * @example - * import popover from 'vue_shared/directives/popover.js'; - * { - * directives: [popover] - * } - * <a v-popover="{options}">popover</a> - */ -export default { - bind(el, binding) { - $(el).popover(binding.value); - }, - - unbind(el) { - $(el).popover('dispose'); - }, -}; 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/help_icon.vue b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue new file mode 100644 index 00000000000..3c606283c7d --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue @@ -0,0 +1,58 @@ +<script> +import { GlButton, GlIcon, GlLink, GlPopover } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlButton, + GlIcon, + GlLink, + GlPopover, + }, + props: { + helpPath: { + type: String, + required: true, + }, + discoverProjectSecurityPath: { + type: String, + required: false, + default: '', + }, + }, + i18n: { + securityReportsHelp: s__('SecurityReports|Security reports help page link'), + upgradeToManageVulnerabilities: s__('SecurityReports|Upgrade to manage vulnerabilities'), + upgradeToInteract: s__( + 'SecurityReports|Upgrade to interact, track and shift left with vulnerability management features in the UI.', + ), + }, +}; +</script> + +<template> + <span v-if="discoverProjectSecurityPath"> + <gl-button + ref="discoverProjectSecurity" + icon="information-o" + category="tertiary" + :aria-label="$options.i18n.upgradeToManageVulnerabilities" + /> + + <gl-popover + :target="() => $refs.discoverProjectSecurity.$el" + :title="$options.i18n.upgradeToManageVulnerabilities" + placement="top" + triggers="click blur" + > + {{ $options.i18n.upgradeToInteract }} + <gl-link :href="discoverProjectSecurityPath" target="_blank" class="gl-font-sm">{{ + __('Learn more') + }}</gl-link> + </gl-popover> + </span> + + <gl-link v-else target="_blank" :href="helpPath" :aria-label="$options.i18n.securityReportsHelp"> + <gl-icon name="question" /> + </gl-link> +</template> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue new file mode 100644 index 00000000000..d7c1e27ff3e --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue @@ -0,0 +1,48 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'SecurityReportDownloadDropdown', + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + artifacts: { + type: Array, + required: true, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + artifactText({ name }) { + return sprintf(s__('SecurityReports|Download %{artifactName}'), { + artifactName: name, + }); + }, + }, +}; +</script> + +<template> + <gl-dropdown + :text="s__('SecurityReports|Download results')" + :loading="loading" + icon="download" + right + > + <gl-dropdown-item + v-for="artifact in artifacts" + :key="artifact.path" + :href="artifact.path" + download + > + {{ artifactText(artifact) }} + </gl-dropdown-item> + </gl-dropdown> +</template> 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..68241a8c5be 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -1,3 +1,32 @@ +import { invert } from 'lodash'; + 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'; + +/** + * SecurityReportTypeEnum values for use with GraphQL. + * + * These should correspond to the lowercase security scan report types. + */ +export const SECURITY_REPORT_TYPE_ENUM_SAST = 'SAST'; +export const SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION = 'SECRET_DETECTION'; + +/** + * A mapping from security scan report types to SecurityReportTypeEnum values. + */ +export const reportTypeToSecurityReportTypeEnum = { + [REPORT_TYPE_SAST]: SECURITY_REPORT_TYPE_ENUM_SAST, + [REPORT_TYPE_SECRET_DETECTION]: SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION, +}; + +/** + * A mapping from SecurityReportTypeEnum values to security scan report types. + */ +export const securityReportTypeEnumToReportType = invert(reportTypeToSecurityReportTypeEnum); diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql new file mode 100644 index 00000000000..310d8d88904 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql @@ -0,0 +1,23 @@ +query securityReportDownloadPaths( + $projectPath: ID! + $iid: String! + $reportTypes: [SecurityReportTypeEnum!] +) { + project(fullPath: $projectPath) { + mergeRequest(iid: $iid) { + headPipeline { + jobs(securityReportTypes: $reportTypes) { + nodes { + name + artifacts { + nodes { + downloadPath + fileType + } + } + } + } + } + } + } +} 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..bdbf9957ad4 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,37 @@ <script> -import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import { mapActions, mapGetters } from 'vuex'; +import { 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 HelpIcon from './components/help_icon.vue'; +import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue'; +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, + reportTypeToSecurityReportTypeEnum, +} from './constants'; +import securityReportDownloadPathsQuery from './queries/security_report_download_paths.query.graphql'; +import { extractSecurityReportArtifacts } from './utils'; export default { + store, components: { - GlIcon, GlLink, GlSprintf, ReportSection, + HelpIcon, + SecurityReportDownloadDropdown, + SecuritySummary, }, + mixins: [glFeatureFlagsMixin()], props: { pipelineId: { type: Number, @@ -27,33 +45,131 @@ export default { type: String, required: true, }, + discoverProjectSecurityPath: { + type: String, + required: false, + default: '', + }, + sastComparisonPath: { + type: String, + required: false, + default: '', + }, + secretScanningComparisonPath: { + type: String, + required: false, + default: '', + }, + targetProjectFullPath: { + type: String, + required: false, + default: '', + }, + mrIid: { + type: Number, + required: false, + default: 0, + }, + canDiscoverProjectSecurity: { + type: Boolean, + required: false, + default: false, + }, }, 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, }; }, + apollo: { + reportArtifacts: { + query: securityReportDownloadPathsQuery, + variables() { + return { + projectPath: this.targetProjectFullPath, + iid: String(this.mrIid), + reportTypes: this.$options.reportTypes.map( + reportType => reportTypeToSecurityReportTypeEnum[reportType], + ), + }; + }, + skip() { + return !this.canShowDownloads; + }, + update(data) { + return extractSecurityReportArtifacts(this.$options.reportTypes, data); + }, + error(error) { + this.showError(error); + }, + result({ loading }) { + if (loading) { + return; + } + + // Query has completed, so populate the availableSecurityReports. + this.onCheckingAvailableSecurityReports( + this.reportArtifacts.map(({ reportType }) => reportType), + ); + }, + }, + }, + computed: { + ...mapGetters(['groupedSummaryText', 'summaryStatus']), + canShowDownloads() { + return this.glFeatures.coreSecurityMrWidgetDownloads; + }, + hasSecurityReports() { + return this.availableSecurityReports.length > 0; + }, + hasSastReports() { + return this.availableSecurityReports.includes(REPORT_TYPE_SAST); + }, + hasSecretDetectionReports() { + return this.availableSecurityReports.includes(REPORT_TYPE_SECRET_DETECTION); + }, + isLoadingReportArtifacts() { + return this.$apollo.queries.reportArtifacts.loading; + }, + shouldShowDownloadGuidance() { + return !this.canShowDownloads && this.summaryStatus !== LOADING; + }, + scansHaveRunMessage() { + return this.canShowDownloads + ? this.$options.i18n.scansHaveRun + : this.$options.i18n.scansHaveRunWithDownloadGuidance; + }, + }, created() { - this.checkHasSecurityReports(this.$options.reportTypes) - .then(hasSecurityReports => { - this.hasSecurityReports = hasSecurityReports; - }) - .catch(error => { - Flash({ - message: this.$options.i18n.apiError, - captureError: true, - error, - }); - }); + if (!this.canShowDownloads) { + this.checkAvailableSecurityReports(this.$options.reportTypes) + .then(availableSecurityReports => { + this.onCheckingAvailableSecurityReports(Array.from(availableSecurityReports)); + }) + .catch(this.showError); + } }, 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,47 +178,127 @@ 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) { window.mrTabs.tabShown('pipelines'); } }, + onCheckingAvailableSecurityReports(availableSecurityReports) { + this.availableSecurityReports = availableSecurityReports; + this.fetchCounts(); + }, + showError(error) { + createFlash({ + message: this.$options.i18n.apiError, + captureError: true, + error, + }); + }, }, - 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.', ), - scansHaveRun: s__( + scansHaveRun: s__('SecurityReports|Security scans have run'), + scansHaveRunWithDownloadGuidance: s__( 'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', ), - securityReportsHelp: s__('SecurityReports|Security reports help page link'), + downloadFromPipelineTab: s__( + 'SecurityReports|Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', + ), }, + 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" /> + + <help-icon + :help-path="securityReportsDocsPath" + :discover-project-security-path="discoverProjectSecurityPath" + /> + </span> + </template> + + <template v-if="shouldShowDownloadGuidance" #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> + + <template v-if="canShowDownloads" #action-buttons> + <security-report-download-dropdown + :artifacts="reportArtifacts" + :loading="isLoadingReportArtifacts" + /> + </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" data-testid="security-mr-widget" > <template #error> - <gl-sprintf :message="$options.i18n.scansHaveRun"> + <gl-sprintf :message="scansHaveRunMessage"> <template #link="{ content }"> <gl-link data-testid="show-pipelines" @click="activatePipelinesTab">{{ content @@ -110,14 +306,17 @@ export default { </template> </gl-sprintf> - <gl-link - target="_blank" - data-testid="help" - :href="securityReportsDocsPath" - :aria-label="$options.i18n.securityReportsHelp" - > - <gl-icon name="question" /> - </gl-link> + <help-icon + :help-path="securityReportsDocsPath" + :discover-project-security-path="discoverProjectSecurityPath" + /> + </template> + + <template v-if="canShowDownloads" #action-buttons> + <security-report-download-dropdown + :artifacts="reportArtifacts" + :loading="isLoadingReportArtifacts" + /> </template> </report-section> </template> 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, + }; +}; diff --git a/app/assets/javascripts/vue_shared/security_reports/utils.js b/app/assets/javascripts/vue_shared/security_reports/utils.js new file mode 100644 index 00000000000..827a87f9aaf --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/utils.js @@ -0,0 +1,22 @@ +import { securityReportTypeEnumToReportType } from './constants'; + +export const extractSecurityReportArtifacts = (reportTypes, data) => { + const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? []; + + return jobs.reduce((acc, job) => { + const artifacts = job.artifacts?.nodes ?? []; + + artifacts.forEach(({ downloadPath, fileType }) => { + const reportType = securityReportTypeEnumToReportType[fileType]; + if (reportType && reportTypes.includes(reportType)) { + acc.push({ + name: job.name, + reportType, + path: downloadPath, + }); + } + }); + + return acc; + }, []); +}; |