summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/vue_shared')
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue39
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/callout.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue142
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_container.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue97
-rw-r--r--app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js142
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue238
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_button.vue61
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue59
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue59
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue36
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue57
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue41
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue70
-rw-r--r--app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue34
-rw-r--r--app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue91
-rw-r--r--app/assets/javascripts/vue_shared/components/members/constants.js71
-rw-r--r--app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue70
-rw-r--r--app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue69
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/created_at.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue99
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/expires_at.vue66
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue57
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/member_source.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table.vue158
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue65
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue95
-rw-r--r--app/assets/javascripts/vue_shared/components/members/utils.js48
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/pikaday.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/select2_select.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue3
-rw-r--r--app/assets/javascripts/vue_shared/directives/popover.js22
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/constants.js8
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue58
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue48
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue59
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js29
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql23
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue275
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/constants.js7
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/getters.js66
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/index.js16
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/messages.js4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/state.js5
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/utils.js78
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/utils.js22
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;
+ }, []);
+};