Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/vue_shared/components')
-rw-r--r--app/assets/javascripts/vue_shared/components/actions_button.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/alert_details_table.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js16
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_modal.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue92
-rw-r--r--app/assets/javascripts/vue_shared/components/editor_lite.vue99
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js121
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/index.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutations.js109
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/state.js47
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue55
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue62
-rw-r--r--app/assets/javascripts/vue_shared/components/local_storage_sync.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue59
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue9
-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.vue61
-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.js70
-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/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.vue110
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue64
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue70
-rw-r--r--app/assets/javascripts/vue_shared/components/members/utils.js19
-rw-r--r--app/assets/javascripts/vue_shared/components/modal_copy_button.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/navigation_tabs.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js21
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue313
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/list_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/title_area.vue78
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js15
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue91
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js31
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue41
-rw-r--r--app/assets/javascripts/vue_shared/components/split_button.vue30
-rw-r--r--app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/timezone_dropdown.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/todo_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/toggle_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue92
86 files changed, 2839 insertions, 433 deletions
diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue
index f333ab49ead..9b21de19185 100644
--- a/app/assets/javascripts/vue_shared/components/actions_button.vue
+++ b/app/assets/javascripts/vue_shared/components/actions_button.vue
@@ -3,7 +3,7 @@ import {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
- GlLink,
+ GlButton,
GlTooltipDirective,
} from '@gitlab/ui';
@@ -12,7 +12,7 @@ export default {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
- GlLink,
+ GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -27,6 +27,16 @@ export default {
required: false,
default: '',
},
+ category: {
+ type: String,
+ required: false,
+ default: 'secondary',
+ },
+ variant: {
+ type: String,
+ required: false,
+ default: 'default',
+ },
},
computed: {
hasMultipleActions() {
@@ -54,6 +64,8 @@ export default {
class="gl-button-deprecated-adapter"
:text="selectedAction.text"
:split-href="selectedAction.href"
+ :variant="variant"
+ :category="category"
split
@click="handleClick(selectedAction, $event)"
>
@@ -77,14 +89,15 @@ export default {
<gl-dropdown-divider v-if="index != actions.length - 1" :key="action.key + '_divider'" />
</template>
</gl-dropdown>
- <gl-link
+ <gl-button
v-else-if="selectedAction"
v-gl-tooltip="selectedAction.tooltip"
v-bind="selectedAction.attrs"
- class="btn"
+ :variant="variant"
+ :category="category"
:href="selectedAction.href"
@click="handleClick(selectedAction, $event)"
>
{{ selectedAction.text }}
- </gl-link>
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/alert_details_table.vue b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
index c94e784c01e..34f6d384f7b 100644
--- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue
+++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
@@ -1,20 +1,38 @@
<script>
import { GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { reduce } from 'lodash';
import { s__ } from '~/locale';
import {
capitalizeFirstCharacter,
convertToSentenceCase,
splitCamelCase,
} from '~/lib/utils/text_utility';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const thClass = 'gl-bg-transparent! gl-border-1! gl-border-b-solid! gl-border-gray-200!';
const tdClass = 'gl-border-gray-100! gl-p-5!';
+const allowedFields = [
+ 'iid',
+ 'title',
+ 'severity',
+ 'status',
+ 'startedAt',
+ 'eventCount',
+ 'monitoringTool',
+ 'service',
+ 'description',
+ 'endedAt',
+ 'details',
+ 'hosts',
+];
+
export default {
components: {
GlLoadingIcon,
GlTable,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
alert: {
type: Object,
@@ -42,14 +60,37 @@ export default {
},
],
computed: {
+ flaggedAllowedFields() {
+ return this.shouldDisplayEnvironment ? [...allowedFields, 'environment'] : allowedFields;
+ },
items() {
if (!this.alert) {
return [];
}
- return Object.entries(this.alert).map(([fieldName, value]) => ({
- fieldName,
- value,
- }));
+ return reduce(
+ this.alert,
+ (allowedItems, fieldValue, fieldName) => {
+ if (this.isAllowed(fieldName)) {
+ let value;
+ if (fieldName === 'environment') {
+ value = fieldValue?.name;
+ } else {
+ value = fieldValue;
+ }
+ return [...allowedItems, { fieldName, value }];
+ }
+ return allowedItems;
+ },
+ [],
+ );
+ },
+ shouldDisplayEnvironment() {
+ return this.glFeatures.exposeEnvironmentPathInAlertDetails;
+ },
+ },
+ methods: {
+ isAllowed(fieldName) {
+ return this.flaggedAllowedFields.includes(fieldName);
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index e1f54b62223..2e4b9b9a135 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 } from '@gitlab/ui';
+import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
import tooltip from '~/vue_shared/directives/tooltip';
import { glEmojiTag } from '../../emoji';
import { __, sprintf } from '~/locale';
@@ -12,6 +12,7 @@ const NO_USER_ID = -1;
export default {
components: {
GlIcon,
+ GlLoadingIcon,
},
directives: {
tooltip,
@@ -184,10 +185,7 @@ export default {
<span class="award-control-icon award-control-icon-super-positive">
<gl-icon aria-hidden="true" name="smiley" />
</span>
- <i
- aria-hidden="true"
- class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"
- ></i>
+ <gl-loading-icon size="md" color="dark" class="award-control-icon-loading" />
</button>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
index 9e2b3097499..7a76888c916 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
@@ -1,9 +1,5 @@
-import {
- SNIPPET_MARK_VIEW_APP_START,
- SNIPPET_MARK_BLOBS_CONTENT,
- SNIPPET_MEASURE_BLOBS_CONTENT,
- SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP,
-} from '~/performance_constants';
+import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance_constants';
+import eventHub from '~/blob/components/eventhub';
export default {
props: {
@@ -17,12 +13,6 @@ export default {
},
},
mounted() {
- window.requestAnimationFrame(() => {
- if (!performance.getEntriesByName(SNIPPET_MARK_BLOBS_CONTENT).length) {
- performance.mark(SNIPPET_MARK_BLOBS_CONTENT);
- performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT);
- performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP, SNIPPET_MARK_VIEW_APP_START);
- }
- });
+ eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT);
},
};
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 d7af3b3298e..1b7e51b7d02 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -7,7 +7,7 @@ import CiIcon from './ci_icon.vue';
*
* Receives status object containing:
* status: {
- * details_path: "/gitlab-org/gitlab-foss/pipelines/8150156" // url
+ * details_path or detailsPath: "/gitlab-org/gitlab-foss/pipelines/8150156" // url
* group:"running" // used for CSS class
* icon: "icon_status_running" // used to render the icon
* label:"running" // used for potential tooltip
@@ -46,6 +46,13 @@ export default {
},
},
computed: {
+ title() {
+ return !this.showText ? this.status?.text : '';
+ },
+ detailsPath() {
+ // For now, this can either come from graphQL with camelCase or REST API in snake_case
+ return this.status.detailsPath || this.status.details_path;
+ },
cssClass() {
const className = this.status.group;
return className ? `ci-status ci-${className} qa-status-badge` : 'ci-status qa-status-badge';
@@ -54,12 +61,7 @@ export default {
};
</script>
<template>
- <a
- v-gl-tooltip
- :href="status.details_path"
- :class="cssClass"
- :title="!showText ? status.text : ''"
- >
+ <a v-gl-tooltip :href="detailsPath" :class="cssClass" :title="title">
<ci-icon :status="status" :css-classes="iconClasses" />
<template v-if="showText">
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index 0234b6bf848..960551fae91 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -12,7 +12,7 @@
* css-class="btn-transparent"
* />
*/
-import { GlDeprecatedButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
export default {
name: 'ClipboardButton',
@@ -20,8 +20,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
- GlDeprecatedButton,
- GlIcon,
+ GlButton,
},
props: {
text: {
@@ -50,7 +49,17 @@ export default {
cssClass: {
type: String,
required: false,
- default: 'btn-default',
+ default: null,
+ },
+ category: {
+ type: String,
+ required: false,
+ default: 'secondary',
+ },
+ size: {
+ type: String,
+ required: false,
+ default: 'medium',
},
},
computed: {
@@ -65,13 +74,15 @@ export default {
</script>
<template>
- <gl-deprecated-button
+ <gl-button
v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
v-gl-tooltip.hover.blur
:class="cssClass"
:title="title"
:data-clipboard-text="clipboardText"
- >
- <gl-icon name="copy-to-clipboard" />
- </gl-deprecated-button>
+ :category="category"
+ :size="size"
+ icon="copy-to-clipboard"
+ :aria-label="__('Copy this value')"
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index c1c8fb3a6e2..e01a651806d 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -139,7 +139,7 @@ export default {
<template>
<div class="branch-commit cgray">
<template v-if="shouldShowRefInfo">
- <div class="icon-container">
+ <div class="icon-container gl-display-inline-block">
<gl-icon v-if="tag" name="tag" />
<gl-icon v-else-if="mergeRequestRef" name="git-merge" />
<gl-icon v-else name="branch" />
diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
index e7f6cc1abc0..a42a606d446 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
@@ -12,6 +12,11 @@ export default {
type: String,
required: true,
},
+ handleSubmit: {
+ type: Function,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -41,7 +46,11 @@ export default {
this.$refs.modal.hide();
},
submitModal() {
- this.$refs.form.submit();
+ if (this.handleSubmit) {
+ this.handleSubmit(this.path);
+ } else {
+ this.$refs.form.submit();
+ }
},
},
csrf,
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
index fe488ab6cfa..5ac30424f98 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
@@ -4,6 +4,11 @@ import ImageViewer from './viewers/image_viewer.vue';
import DownloadViewer from './viewers/download_viewer.vue';
export default {
+ components: {
+ MarkdownViewer,
+ ImageViewer,
+ DownloadViewer,
+ },
props: {
content: {
type: String,
@@ -45,35 +50,25 @@ export default {
default: () => ({}),
},
},
- computed: {
- viewer() {
- if (!this.path) return null;
- if (!this.type) return DownloadViewer;
-
- switch (this.type) {
- case 'markdown':
- return MarkdownViewer;
- case 'image':
- return ImageViewer;
- default:
- return DownloadViewer;
- }
- },
- },
};
</script>
<template>
<div class="preview-container">
- <component
- :is="viewer"
- :path="path"
+ <image-viewer v-if="type === 'image'" :path="path" :file-size="fileSize" />
+ <markdown-viewer
+ v-if="type === 'markdown'"
+ :content="content"
+ :commit-sha="commitSha"
:file-path="filePath"
- :file-size="fileSize"
:project-path="projectPath"
- :content="content"
:images="images"
- :commit-sha="commitSha"
+ />
+ <download-viewer
+ v-if="!type && path"
+ :path="path"
+ :file-path="filePath"
+ :file-size="fileSize"
/>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
index f9d3d76e7f5..8d55701f499 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
@@ -1,10 +1,9 @@
<script>
-import { GlLink, GlIcon } from '@gitlab/ui';
+import { GlIcon } from '@gitlab/ui';
import { numberToHumanSize } from '../../../../lib/utils/number_utils';
export default {
components: {
- GlLink,
GlIcon,
},
props: {
@@ -44,16 +43,10 @@ export default {
({{ fileSizeReadable }})
</template>
</p>
- <gl-link
- :href="path"
- class="btn btn-default"
- rel="nofollow"
- :download="fileName"
- target="_blank"
- >
+ <a :href="path" class="btn btn-default" rel="nofollow" :download="fileName" target="_blank">
<gl-icon :size="16" name="download" class="float-left gl-mr-3" />
{{ __('Download') }}
- </gl-link>
+ </a>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue
index 543547b37fe..c4bce860ae4 100644
--- a/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue
+++ b/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue
@@ -1,5 +1,6 @@
<script>
import $ from 'jquery';
+import { GlButton } from '@gitlab/ui';
const buttonVariants = ['danger', 'primary', 'success', 'warning'];
const sizeVariants = ['sm', 'md', 'lg', 'xl'];
@@ -7,6 +8,9 @@ const sizeVariants = ['sm', 'md', 'lg', 'xl'];
export default {
name: 'DeprecatedModal2', // use GlModal instead
+ components: {
+ GlButton,
+ },
props: {
id: {
type: String,
@@ -72,20 +76,21 @@ export default {
<div :id="id" class="modal fade" tabindex="-1" role="dialog">
<div :class="modalSizeClass" class="modal-dialog" role="document">
<div class="modal-content">
- <div class="modal-header">
+ <div class="modal-header gl-pr-4">
<slot name="header">
<h4 class="modal-title">
<slot name="title"> {{ headerTitleText }} </slot>
</h4>
- <button
+ <gl-button
:aria-label="s__('Modal|Close')"
- type="button"
- class="close js-modal-close-action"
+ variant="default"
+ category="tertiary"
+ size="small"
+ icon="close"
+ class="js-modal-close-action"
data-dismiss="modal"
@click="emitCancel($event)"
- >
- <span aria-hidden="true">&times;</span>
- </button>
+ />
</slot>
</div>
@@ -93,23 +98,21 @@ export default {
<div class="modal-footer">
<slot name="footer">
- <button
- type="button"
- class="btn js-modal-cancel-action qa-modal-cancel-button"
+ <gl-button
+ class="js-modal-cancel-action qa-modal-cancel-button"
data-dismiss="modal"
@click="emitCancel($event)"
>
{{ s__('Modal|Cancel') }}
- </button>
- <button
+ </gl-button>
+ <gl-button
:class="`btn-${footerPrimaryButtonVariant}`"
- type="button"
- class="btn js-modal-primary-action qa-modal-primary-button"
+ class="js-modal-primary-action qa-modal-primary-button"
data-dismiss="modal"
@click="emitSubmit($event)"
>
{{ footerPrimaryButtonText }}
- </button>
+ </gl-button>
</slot>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
index c7d7c3a1d24..2a28b13e7bf 100644
--- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
+++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
@@ -22,7 +22,7 @@ export default {
},
data() {
return {
- isDismissed: 'false',
+ isDismissed: false,
};
},
computed: {
@@ -30,12 +30,12 @@ export default {
return `${slugifyWithUnderscore(this.featureName)}_feedback_dismissed`;
},
showAlert() {
- return this.isDismissed === 'false';
+ return !this.isDismissed;
},
},
methods: {
dismissFeedbackAlert() {
- this.isDismissed = 'true';
+ this.isDismissed = true;
},
},
};
@@ -43,16 +43,12 @@ export default {
<template>
<div v-show="showAlert">
- <local-storage-sync
- :value="isDismissed"
- :storage-key="storageKey"
- @input="dismissFeedbackAlert"
- />
+ <local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json />
<gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissFeedbackAlert">
<gl-sprintf
:message="
__(
- 'We’ve been making changes to %{featureName} and we’d love your feedback %{linkStart}in this issue%{linkEnd} to help us improve the experience.',
+ 'Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience.',
)
"
>
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 7157337f8f3..48b94fdc181 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
@@ -1,7 +1,11 @@
<script>
+import { GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
+ components: {
+ GlIcon,
+ },
props: {
placeholderText: {
type: String,
@@ -40,6 +44,6 @@ export default {
type="search"
autocomplete="off"
/>
- <i class="fa fa-search dropdown-input-search" aria-hidden="true" data-hidden="true"> </i>
+ <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" data-hidden="true" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
deleted file mode 100644
index 4d85726065b..00000000000
--- a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
+++ /dev/null
@@ -1,92 +0,0 @@
-<script>
-import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
-
-export default {
- components: {
- GlIcon,
- GlDeprecatedButton,
- },
- props: {
- size: {
- type: String,
- required: false,
- default: '',
- },
- primaryButtonClass: {
- type: String,
- required: false,
- default: '',
- },
- dropdownClass: {
- type: String,
- required: false,
- default: '',
- },
- actions: {
- type: Array,
- required: true,
- },
- defaultAction: {
- type: Number,
- required: true,
- },
- },
- data() {
- return {
- selectedAction: this.defaultAction,
- };
- },
- computed: {
- selectedActionTitle() {
- return this.actions[this.selectedAction].title;
- },
- buttonSizeClass() {
- return `btn-${this.size}`;
- },
- },
- methods: {
- handlePrimaryActionClick() {
- this.$emit('onActionClick', this.actions[this.selectedAction]);
- },
- handleActionClick(selectedAction) {
- this.selectedAction = selectedAction;
- this.$emit('onActionSelect', selectedAction);
- },
- },
-};
-</script>
-
-<template>
- <div class="btn-group droplab-dropdown comment-type-dropdown">
- <gl-deprecated-button
- :class="primaryButtonClass"
- :size="size"
- @click.prevent="handlePrimaryActionClick"
- >
- {{ selectedActionTitle }}
- </gl-deprecated-button>
- <button
- :class="buttonSizeClass"
- type="button"
- class="btn dropdown-toggle pl-2 pr-2"
- data-display="static"
- data-toggle="dropdown"
- >
- <gl-icon name="chevron-down" :aria-label="__('toggle dropdown')" />
- </button>
- <ul :class="dropdownClass" class="dropdown-menu dropdown-open-top">
- <template v-for="(action, index) in actions">
- <li :key="index" :class="{ 'droplab-item-selected': selectedAction === index }">
- <gl-deprecated-button class="btn-transparent" @click.prevent="handleActionClick(index)">
- <i aria-hidden="true" class="fa fa-check icon"> </i>
- <div class="description">
- <strong>{{ action.title }}</strong>
- <p>{{ action.description }}</p>
- </div>
- </gl-deprecated-button>
- </li>
- <li v-if="index === 0" :key="`${index}-separator`" class="divider droplab-item-ignore"></li>
- </template>
- </ul>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/editor_lite.vue b/app/assets/javascripts/vue_shared/components/editor_lite.vue
new file mode 100644
index 00000000000..cfe3ce0a11c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/editor_lite.vue
@@ -0,0 +1,99 @@
+<script>
+import { debounce } from 'lodash';
+import Editor from '~/editor/editor_lite';
+import { CONTENT_UPDATE_DEBOUNCE } from '~/editor/constants';
+
+function initEditorLite({ el, ...args }) {
+ const editor = new Editor({
+ scrollbar: {
+ alwaysConsumeMouseWheel: false,
+ },
+ });
+
+ return editor.createInstance({
+ el,
+ ...args,
+ });
+}
+
+export default {
+ inheritAttrs: false,
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ fileName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ // This is used to help uniquely create a monaco model
+ // even if two blob's share a file path.
+ fileGlobalId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ extensions: {
+ type: [String, Array],
+ required: false,
+ default: () => null,
+ },
+ editorOptions: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ loading: true,
+ editor: null,
+ };
+ },
+ watch: {
+ fileName(newVal) {
+ this.editor.updateModelLanguage(newVal);
+ },
+ value(newVal) {
+ if (this.editor.getValue() !== newVal) {
+ this.editor.setValue(newVal);
+ }
+ },
+ },
+ mounted() {
+ this.editor = initEditorLite({
+ el: this.$refs.editor,
+ blobPath: this.fileName,
+ blobContent: this.value,
+ blobGlobalId: this.fileGlobalId,
+ extensions: this.extensions,
+ ...this.editorOptions,
+ });
+
+ this.editor.onDidChangeModelContent(
+ debounce(this.onFileChange.bind(this), CONTENT_UPDATE_DEBOUNCE),
+ );
+ },
+ beforeDestroy() {
+ this.editor.dispose();
+ },
+ methods: {
+ onFileChange() {
+ this.$emit('input', this.editor.getValue());
+ },
+ },
+};
+</script>
+<template>
+ <div
+ :id="`editor-lite-${fileGlobalId}`"
+ ref="editor"
+ data-editor-loading
+ @editor-ready="$emit('editor-ready')"
+ >
+ <pre class="editor-loading-content">{{ value }}</pre>
+ </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 012aca8105a..386df617d47 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -230,13 +230,12 @@ export default {
@keydown="onKeydown($event)"
@keyup="onKeyup($event)"
/>
- <i
- :class="{
- hidden: showClearInputButton,
- }"
+ <gl-icon
+ name="search"
+ class="dropdown-input-search"
+ :class="{ hidden: showClearInputButton }"
aria-hidden="true"
- class="fa fa-search dropdown-input-search"
- ></i>
+ />
<gl-icon
name="close"
class="dropdown-input-clear"
diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
index b70f093e930..91a0ac3aa92 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
+++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
@@ -9,6 +9,12 @@ const fileExtensionIcons = {
'md.rendered': 'markdown',
markdown: 'markdown',
'markdown.rendered': 'markdown',
+ mdown: 'markdown',
+ 'mdown.rendered': 'markdown',
+ mkd: 'markdown',
+ 'mkd.rendered': 'markdown',
+ mkdn: 'markdown',
+ 'mkdn.rendered': 'markdown',
rst: 'markdown',
blink: 'blink',
css: 'css',
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
new file mode 100644
index 00000000000..443cb28cf10
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
@@ -0,0 +1,121 @@
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import Api from '~/api';
+import * as types from './mutation_types';
+
+export const setEndpoints = ({ commit }, params) => {
+ const { milestonesEndpoint, labelsEndpoint, groupEndpoint, projectEndpoint } = params;
+ commit(types.SET_MILESTONES_ENDPOINT, milestonesEndpoint);
+ commit(types.SET_LABELS_ENDPOINT, labelsEndpoint);
+ commit(types.SET_GROUP_ENDPOINT, groupEndpoint);
+ commit(types.SET_PROJECT_ENDPOINT, projectEndpoint);
+};
+
+export function fetchBranches({ commit, state }, search = '') {
+ const { projectEndpoint } = state;
+ commit(types.REQUEST_BRANCHES);
+
+ return Api.branches(projectEndpoint, search)
+ .then(response => {
+ commit(types.RECEIVE_BRANCHES_SUCCESS, response.data);
+ return response;
+ })
+ .catch(({ response }) => {
+ const { status } = response;
+ commit(types.RECEIVE_BRANCHES_ERROR, status);
+ createFlash(__('Failed to load branches. Please try again.'));
+ });
+}
+
+export const fetchMilestones = ({ commit, state }, search_title = '') => {
+ commit(types.REQUEST_MILESTONES);
+ const { milestonesEndpoint } = state;
+
+ return axios
+ .get(milestonesEndpoint, { params: { search_title } })
+ .then(response => {
+ commit(types.RECEIVE_MILESTONES_SUCCESS, response.data);
+ return response;
+ })
+ .catch(({ response }) => {
+ const { status } = response;
+ commit(types.RECEIVE_MILESTONES_ERROR, status);
+ createFlash(__('Failed to load milestones. Please try again.'));
+ });
+};
+
+export const fetchLabels = ({ commit, state }, search = '') => {
+ commit(types.REQUEST_LABELS);
+
+ return axios
+ .get(state.labelsEndpoint, { params: { search } })
+ .then(response => {
+ commit(types.RECEIVE_LABELS_SUCCESS, response.data);
+ return response;
+ })
+ .catch(({ response }) => {
+ const { status } = response;
+ commit(types.RECEIVE_LABELS_ERROR, status);
+ createFlash(__('Failed to load labels. Please try again.'));
+ });
+};
+
+function fetchUser(options = {}) {
+ const { commit, projectEndpoint, groupEndpoint, query, action, errorMessage } = options;
+ commit(`REQUEST_${action}`);
+
+ let fetchUserPromise;
+ if (projectEndpoint) {
+ fetchUserPromise = Api.projectUsers(projectEndpoint, query).then(data => ({ data }));
+ } else {
+ fetchUserPromise = Api.groupMembers(groupEndpoint, { query });
+ }
+
+ return fetchUserPromise
+ .then(response => {
+ commit(`RECEIVE_${action}_SUCCESS`, response.data);
+ return response;
+ })
+ .catch(({ response }) => {
+ const { status } = response;
+ commit(`RECEIVE_${action}_ERROR`, status);
+ createFlash(errorMessage);
+ });
+}
+
+export const fetchAuthors = ({ commit, state }, query = '') => {
+ const { projectEndpoint, groupEndpoint } = state;
+
+ return fetchUser({
+ commit,
+ query,
+ projectEndpoint,
+ groupEndpoint,
+ action: 'AUTHORS',
+ errorMessage: __('Failed to load authors. Please try again.'),
+ });
+};
+
+export const fetchAssignees = ({ commit, state }, query = '') => {
+ const { projectEndpoint, groupEndpoint } = state;
+
+ return fetchUser({
+ commit,
+ query,
+ projectEndpoint,
+ groupEndpoint,
+ action: 'ASSIGNEES',
+ errorMessage: __('Failed to load assignees. Please try again.'),
+ });
+};
+
+export const setFilters = ({ commit, dispatch }, filters) => {
+ commit(types.SET_SELECTED_FILTERS, filters);
+
+ return dispatch('setFilters', filters, { root: true });
+};
+
+export const initialize = ({ commit }, initialFilters) => {
+ commit(types.SET_SELECTED_FILTERS, initialFilters);
+};
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/index.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/index.js
new file mode 100644
index 00000000000..665bb29a17e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default {
+ namespaced: true,
+ actions,
+ mutations,
+ state: state(),
+};
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types.js
new file mode 100644
index 00000000000..07163550524
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types.js
@@ -0,0 +1,26 @@
+export const SET_MILESTONES_ENDPOINT = 'SET_MILESTONES_ENDPOINT';
+export const SET_LABELS_ENDPOINT = 'SET_LABELS_ENDPOINT';
+export const SET_GROUP_ENDPOINT = 'SET_GROUP_ENDPOINT';
+export const SET_PROJECT_ENDPOINT = 'SET_PROJECT_ENDPOINT';
+
+export const REQUEST_BRANCHES = 'REQUEST_BRANCHES';
+export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS';
+export const RECEIVE_BRANCHES_ERROR = 'RECEIVE_BRANCHES_ERROR';
+
+export const REQUEST_MILESTONES = 'REQUEST_MILESTONES';
+export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
+export const RECEIVE_MILESTONES_ERROR = 'RECEIVE_MILESTONES_ERROR';
+
+export const REQUEST_LABELS = 'REQUEST_LABELS';
+export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
+export const RECEIVE_LABELS_ERROR = 'RECEIVE_LABELS_ERROR';
+
+export const REQUEST_AUTHORS = 'REQUEST_AUTHORS';
+export const RECEIVE_AUTHORS_SUCCESS = 'RECEIVE_AUTHORS_SUCCESS';
+export const RECEIVE_AUTHORS_ERROR = 'RECEIVE_AUTHORS_ERROR';
+
+export const REQUEST_ASSIGNEES = 'REQUEST_ASSIGNEES';
+export const RECEIVE_ASSIGNEES_SUCCESS = 'RECEIVE_ASSIGNEES_SUCCESS';
+export const RECEIVE_ASSIGNEES_ERROR = 'RECEIVE_ASSIGNEES_ERROR';
+
+export const SET_SELECTED_FILTERS = 'SET_SELECTED_FILTERS';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutations.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutations.js
new file mode 100644
index 00000000000..056b1c6310f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutations.js
@@ -0,0 +1,109 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_SELECTED_FILTERS](state, params) {
+ const {
+ selectedSourceBranch = null,
+ selectedSourceBranchList = [],
+ selectedTargetBranch = null,
+ selectedTargetBranchList = [],
+ selectedAuthor = null,
+ selectedAuthorList = [],
+ selectedMilestone = null,
+ selectedMilestoneList = [],
+ selectedAssignee = null,
+ selectedAssigneeList = [],
+ selectedLabel = null,
+ selectedLabelList = [],
+ } = params;
+ state.branches.source.selected = selectedSourceBranch;
+ state.branches.source.selectedList = selectedSourceBranchList;
+ state.branches.target.selected = selectedTargetBranch;
+ state.branches.target.selectedList = selectedTargetBranchList;
+ state.authors.selected = selectedAuthor;
+ state.authors.selectedList = selectedAuthorList;
+ state.assignees.selected = selectedAssignee;
+ state.assignees.selectedList = selectedAssigneeList;
+ state.milestones.selected = selectedMilestone;
+ state.milestones.selectedList = selectedMilestoneList;
+ state.labels.selected = selectedLabel;
+ state.labels.selectedList = selectedLabelList;
+ },
+ [types.SET_MILESTONES_ENDPOINT](state, milestonesEndpoint) {
+ state.milestonesEndpoint = milestonesEndpoint;
+ },
+ [types.SET_LABELS_ENDPOINT](state, labelsEndpoint) {
+ state.labelsEndpoint = labelsEndpoint;
+ },
+ [types.SET_GROUP_ENDPOINT](state, groupEndpoint) {
+ state.groupEndpoint = groupEndpoint;
+ },
+ [types.SET_PROJECT_ENDPOINT](state, projectEndpoint) {
+ state.projectEndpoint = projectEndpoint;
+ },
+ [types.REQUEST_MILESTONES](state) {
+ state.milestones.isLoading = true;
+ },
+ [types.RECEIVE_MILESTONES_SUCCESS](state, data) {
+ state.milestones.isLoading = false;
+ state.milestones.data = data;
+ state.milestones.errorCode = null;
+ },
+ [types.RECEIVE_MILESTONES_ERROR](state, errorCode) {
+ state.milestones.isLoading = false;
+ state.milestones.errorCode = errorCode;
+ state.milestones.data = [];
+ },
+ [types.REQUEST_LABELS](state) {
+ state.labels.isLoading = true;
+ },
+ [types.RECEIVE_LABELS_SUCCESS](state, data) {
+ state.labels.isLoading = false;
+ state.labels.data = data;
+ state.labels.errorCode = null;
+ },
+ [types.RECEIVE_LABELS_ERROR](state, errorCode) {
+ state.labels.isLoading = false;
+ state.labels.errorCode = errorCode;
+ state.labels.data = [];
+ },
+ [types.REQUEST_AUTHORS](state) {
+ state.authors.isLoading = true;
+ },
+ [types.RECEIVE_AUTHORS_SUCCESS](state, data) {
+ state.authors.isLoading = false;
+ state.authors.data = data;
+ state.authors.errorCode = null;
+ },
+ [types.RECEIVE_AUTHORS_ERROR](state, errorCode) {
+ state.authors.isLoading = false;
+ state.authors.errorCode = errorCode;
+ state.authors.data = [];
+ },
+ [types.REQUEST_ASSIGNEES](state) {
+ state.assignees.isLoading = true;
+ },
+ [types.RECEIVE_ASSIGNEES_SUCCESS](state, data) {
+ state.assignees.isLoading = false;
+ state.assignees.data = data;
+ state.assignees.errorCode = null;
+ },
+ [types.RECEIVE_ASSIGNEES_ERROR](state, errorCode) {
+ state.assignees.isLoading = false;
+ state.assignees.errorCode = errorCode;
+ state.assignees.data = [];
+ },
+ [types.REQUEST_BRANCHES](state) {
+ state.branches.isLoading = true;
+ },
+ [types.RECEIVE_BRANCHES_SUCCESS](state, data) {
+ state.branches.isLoading = false;
+ state.branches.data = data;
+ state.branches.errorCode = null;
+ },
+ [types.RECEIVE_BRANCHES_ERROR](state, errorCode) {
+ state.branches.isLoading = false;
+ state.branches.errorCode = errorCode;
+ state.branches.data = [];
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/state.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/state.js
new file mode 100644
index 00000000000..f89f5efc341
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/state.js
@@ -0,0 +1,47 @@
+export default () => ({
+ milestonesEndpoint: '',
+ labelsEndpoint: '',
+ groupEndpoint: '',
+ projectEndpoint: '',
+ branches: {
+ isLoading: false,
+ errorCode: null,
+ data: [],
+ source: {
+ selected: null,
+ selectedList: [],
+ },
+ target: {
+ selected: null,
+ selectedList: [],
+ },
+ },
+ milestones: {
+ isLoading: false,
+ errorCode: null,
+ data: [],
+ selected: null,
+ selectedList: [],
+ },
+ labels: {
+ isLoading: false,
+ errorCode: null,
+ data: [],
+ selected: null,
+ selectedList: [],
+ },
+ authors: {
+ isLoading: false,
+ errorCode: null,
+ data: [],
+ selected: null,
+ selectedList: [],
+ },
+ assignees: {
+ isLoading: false,
+ errorCode: null,
+ data: [],
+ selected: null,
+ selectedList: [],
+ },
+});
diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
index da4b0aedef5..e895a7a52ab 100644
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
@@ -1,5 +1,5 @@
<script>
-import { escape } from 'lodash';
+import { escape, last } from 'lodash';
import Tribute from 'tributejs';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
@@ -12,6 +12,8 @@ const AutoComplete = {
MergeRequests: 'mergeRequests',
};
+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];
@@ -74,30 +76,40 @@ const autoCompleteMap = {
return this.members;
},
menuItemTemplate({ original }) {
- const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : '';
-
- const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass}
- gl-display-inline-flex! gl-align-items-center gl-justify-content-center`;
-
- const avatarTag = original.avatar_url
- ? `<img
- src="${original.avatar_url}"
- alt="${original.username}'s avatar"
- class="${avatarClasses}"/>`
- : `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`;
-
- const name = escape(original.name);
+ 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 icon = original.mentionsDisabled
- ? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3')
+ const disabledMentionsIcon = original.mentionsDisabled
+ ? spriteIcon('notifications-off', 's16 gl-ml-3')
: '';
- return `${avatarTag}
- ${original.username}
- <small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small>
- ${icon}`;
+ 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]: {
@@ -134,7 +146,8 @@ export default {
{
trigger: '@',
fillAttr: 'username',
- lookup: value => value.name + value.username,
+ lookup: value =>
+ value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username,
menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate,
values: this.getValues(AutoComplete.Members),
},
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index 6ff6f10f786..79d9ba6df57 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -1,10 +1,11 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlTooltipDirective, GlLink, GlDeprecatedButton } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
+import { GlTooltipDirective, GlLink, GlButton, GlTooltip } from '@gitlab/ui';
import CiIconBadge from './ci_badge_link.vue';
import TimeagoTooltip from './time_ago_tooltip.vue';
import UserAvatarImage from './user_avatar/user_avatar_image.vue';
+import { glEmojiTag } from '../../emoji';
+import { __, sprintf } from '../../locale';
/**
* Renders header component for job and pipeline page based on UI mockups
@@ -19,11 +20,13 @@ export default {
TimeagoTooltip,
UserAvatarImage,
GlLink,
- GlDeprecatedButton,
+ GlButton,
+ GlTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ EMOJI_REF: 'EMOJI_REF',
props: {
status: {
type: Object,
@@ -62,6 +65,27 @@ export default {
userAvatarAltText() {
return sprintf(__(`%{username}'s avatar`), { username: this.user.name });
},
+ userPath() {
+ // GraphQL returns `webPath` and Rest `path`
+ return this.user?.webPath || this.user?.path;
+ },
+ avatarUrl() {
+ // GraphQL returns `avatarUrl` and Rest `avatar_url`
+ return this.user?.avatarUrl || this.user?.avatar_url;
+ },
+ statusTooltipHTML() {
+ // Rest `status_tooltip_html` which is a ready to work
+ // html for the emoji and the status text inside a tooltip.
+ // GraphQL returns `status.emoji` and `status.message` which
+ // needs to be combined to make the html we want.
+ const { emoji } = this.user?.status || {};
+ const emojiHtml = emoji ? glEmojiTag(emoji) : '';
+
+ return emojiHtml || this.user?.status_tooltip_html;
+ },
+ message() {
+ return this.user?.status?.message;
+ },
},
methods: {
@@ -73,7 +97,11 @@ export default {
</script>
<template>
- <header class="page-content-header ci-header-container">
+ <header
+ class="page-content-header gl-display-flex gl-min-h-7"
+ data-qa-selector="pipeline_header"
+ data-testid="ci-header-content"
+ >
<section class="header-main-content">
<ci-icon-badge :status="status" />
@@ -89,12 +117,12 @@ export default {
<template v-if="user">
<gl-link
v-gl-tooltip
- :href="user.path"
+ :href="userPath"
:title="user.email"
class="js-user-link commit-committer-link"
>
<user-avatar-image
- :img-src="user.avatar_url"
+ :img-src="avatarUrl"
:img-alt="userAvatarAltText"
:tooltip-text="user.name"
:img-size="24"
@@ -102,21 +130,27 @@ export default {
{{ user.name }}
</gl-link>
- <span v-if="user.status_tooltip_html" v-html="user.status_tooltip_html"></span>
+ <gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]">
+ {{ message }}
+ </gl-tooltip>
+ <span
+ v-if="statusTooltipHTML"
+ :ref="$options.EMOJI_REF"
+ :data-testid="message"
+ v-html="statusTooltipHTML"
+ ></span>
</template>
</section>
<section v-if="$slots.default" data-testid="headerButtons" class="gl-display-flex">
<slot></slot>
</section>
- <gl-deprecated-button
+ <gl-button
v-if="hasSidebarButton"
- id="toggleSidebar"
- class="d-block d-sm-none
-sidebar-toggle-btn js-sidebar-build-toggle js-sidebar-build-toggle-header"
+ class="d-sm-none js-sidebar-build-toggle gl-ml-auto"
+ icon="angle-double-left"
+ :aria-label="__('Toggle sidebar')"
@click="onClickSidebarButton"
- >
- <i class="fa fa-angle-double-left" aria-hidden="true" aria-labelledby="toggleSidebar"> </i>
- </gl-deprecated-button>
+ />
</header>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
index b5d6b872547..80c03342f11 100644
--- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
+++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
@@ -1,4 +1,6 @@
<script>
+import { isEqual } from 'lodash';
+
export default {
props: {
storageKey: {
@@ -6,31 +8,65 @@ export default {
required: true,
},
value: {
- type: String,
+ type: [String, Number, Boolean, Array, Object],
required: false,
default: '',
},
+ asJson: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ persist: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
watch: {
value(newVal) {
- this.saveValue(newVal);
+ this.saveValue(this.serialize(newVal));
},
},
mounted() {
// On mount, trigger update if we actually have a localStorageValue
- const value = this.getValue();
+ const { exists, value } = this.getStorageValue();
- if (value && this.value !== value) {
+ if (exists && !isEqual(value, this.value)) {
this.$emit('input', value);
}
},
methods: {
- getValue() {
- return localStorage.getItem(this.storageKey);
+ getStorageValue() {
+ const value = localStorage.getItem(this.storageKey);
+
+ if (value === null) {
+ return { exists: false };
+ }
+
+ try {
+ return { exists: true, value: this.deserialize(value) };
+ } catch {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[gitlab] Failed to deserialize value from localStorage (key=${this.storageKey})`,
+ value,
+ );
+ // default to "don't use localStorage value"
+ return { exists: false };
+ }
},
saveValue(val) {
+ if (!this.persist) return;
+
localStorage.setItem(this.storageKey, val);
},
+ serialize(val) {
+ return this.asJson ? JSON.stringify(val) : val;
+ },
+ deserialize(val) {
+ return this.asJson ? JSON.parse(val) : val;
+ },
},
render() {
return this.$slots.default;
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index a48c279d0e3..65116ed8ca3 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -25,6 +25,18 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
props: {
+ /**
+ * This prop should be bound to the value of the `<textarea>` element
+ * that is rendered as a child of this component (in the `textarea` slot)
+ */
+ textareaValue: {
+ type: String,
+ required: true,
+ },
+ markdownDocsPath: {
+ type: String,
+ required: true,
+ },
isSubmitting: {
type: Boolean,
required: false,
@@ -35,10 +47,6 @@ export default {
required: false,
default: '',
},
- markdownDocsPath: {
- type: String,
- required: true,
- },
addSpacingClasses: {
type: Boolean,
required: false,
@@ -84,12 +92,6 @@ export default {
required: false,
default: false,
},
- // This prop is used as a fallback in case if textarea.elm is undefined
- textareaValue: {
- type: String,
- required: false,
- default: '',
- },
},
data() {
return {
@@ -165,17 +167,20 @@ export default {
},
mounted() {
// GLForm class handles all the toolbar buttons
- return new GLForm($(this.$refs['gl-form']), {
- emojis: this.enableAutocomplete,
- members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- epics: this.enableAutocomplete,
- milestones: this.enableAutocomplete,
- labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- snippets: this.enableAutocomplete,
- vulnerabilities: this.enableAutocomplete,
- });
+ return new GLForm(
+ $(this.$refs['gl-form']),
+ {
+ emojis: this.enableAutocomplete,
+ members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
+ issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
+ mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
+ epics: this.enableAutocomplete,
+ milestones: this.enableAutocomplete,
+ labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
+ snippets: this.enableAutocomplete,
+ },
+ true,
+ );
},
beforeDestroy() {
const glForm = $(this.$refs['gl-form']).data('glForm');
@@ -189,17 +194,11 @@ export default {
this.previewMarkdown = true;
- /*
- Can't use `$refs` as the component is technically in the parent component
- so we access the VNode & then get the element
- */
- const text = this.$slots.textarea[0]?.elm?.value || this.textareaValue;
-
- if (text) {
+ if (this.textareaValue) {
this.markdownPreviewLoading = true;
this.markdownPreview = __('Loading…');
axios
- .post(this.markdownPreviewPath, { text })
+ .post(this.markdownPreviewPath, { text: this.textareaValue })
.then(response => this.renderMarkdown(response.data))
.catch(() => new Flash(__('Error loading markdown preview')));
} else {
@@ -234,7 +233,7 @@ export default {
<div
ref="gl-form"
:class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }"
- class="js-vue-markdown-field md-area position-relative"
+ class="js-vue-markdown-field md-area position-relative gfm-form"
>
<markdown-header
:preview-markdown="previewMarkdown"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index 13c42d35b04..13ec7a6ada9 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -27,6 +27,11 @@ export default {
type: String,
required: true,
},
+ suggestionsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
computed: {
batchSuggestionsCount() {
@@ -62,6 +67,7 @@ export default {
<div class="md-suggestion">
<suggestion-diff-header
class="qa-suggestion-diff-header js-suggestion-diff-header"
+ :suggestions-count="suggestionsCount"
:can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
:is-applied="suggestion.applied"
:is-batched="isBatched"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 1fc54d2f52e..fb9636ba734 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -42,6 +42,11 @@ export default {
required: false,
default: null,
},
+ suggestionsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -127,7 +132,7 @@ export default {
</div>
<div v-else class="d-flex align-items-center">
<gl-button
- v-if="canBeBatched && !isDisableButton"
+ v-if="suggestionsCount > 1 && canBeBatched && !isDisableButton"
class="btn-inverted js-add-to-batch-btn btn-grouped"
:disabled="isDisableButton"
@click="addSuggestionToBatch"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 083f581af05..927a93487e6 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -38,6 +38,11 @@ export default {
type: String,
required: true,
},
+ suggestionsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -77,12 +82,12 @@ export default {
this.isRendered = true;
},
generateDiff(suggestionIndex) {
- const { suggestions, disabled, batchSuggestionsInfo, helpPagePath } = this;
+ const { suggestions, disabled, batchSuggestionsInfo, helpPagePath, suggestionsCount } = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
const suggestionDiff = new SuggestionDiffComponent({
- propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath },
+ propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath, suggestionsCount },
});
suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
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
new file mode 100644
index 00000000000..10078d5cd64
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue
@@ -0,0 +1,59 @@
+<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
new file mode 100644
index 00000000000..8356fdb60b1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue
@@ -0,0 +1,11 @@
+<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
new file mode 100644
index 00000000000..e8a53ff173d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue
@@ -0,0 +1,42 @@
+<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
new file mode 100644
index 00000000000..2aebfe80db5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue
@@ -0,0 +1,27 @@
+<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
new file mode 100644
index 00000000000..2b0a75640e2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue
@@ -0,0 +1,48 @@
+<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
new file mode 100644
index 00000000000..d9976e7181c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue
@@ -0,0 +1,40 @@
+<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
new file mode 100644
index 00000000000..9d89cb40676
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue
@@ -0,0 +1,36 @@
+<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
new file mode 100644
index 00000000000..b0b7ff4ce9a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue
@@ -0,0 +1,57 @@
+<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
new file mode 100644
index 00000000000..1cc3fd17e98
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue
@@ -0,0 +1,41 @@
+<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
new file mode 100644
index 00000000000..8fa3d439fc1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue
@@ -0,0 +1,61 @@
+<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 },
+ 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>
+ </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
new file mode 100644
index 00000000000..12b748f9ab6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue
@@ -0,0 +1,34 @@
+<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
new file mode 100644
index 00000000000..28654a60860
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue
@@ -0,0 +1,32 @@
+<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
new file mode 100644
index 00000000000..e5e7cdf149c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue
@@ -0,0 +1,91 @@
+<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
new file mode 100644
index 00000000000..6509779053e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/constants.js
@@ -0,0 +1,70 @@
+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',
+ },
+];
+
+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
new file mode 100644
index 00000000000..9a2ce0d4931
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue
@@ -0,0 +1,70 @@
+<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
new file mode 100644
index 00000000000..e8890717724
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue
@@ -0,0 +1,69 @@
+<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
new file mode 100644
index 00000000000..0bad70894f9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/created_at.vue
@@ -0,0 +1,40 @@
+<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/expires_at.vue b/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue
new file mode 100644
index 00000000000..de65e3fb10f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue
@@ -0,0 +1,66 @@
+<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
new file mode 100644
index 00000000000..320d8c99223
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue
@@ -0,0 +1,57 @@
+<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
new file mode 100644
index 00000000000..a1f98d4008a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue
@@ -0,0 +1,35 @@
+<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
new file mode 100644
index 00000000000..030d72c3420
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/member_source.vue
@@ -0,0 +1,27 @@
+<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
new file mode 100644
index 00000000000..c1a80a85dbe
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
@@ -0,0 +1,110 @@
+<script>
+import { mapState } from 'vuex';
+import { GlTable, GlBadge } from '@gitlab/ui';
+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 MembersTableCell from './members_table_cell.vue';
+import RoleDropdown from './role_dropdown.vue';
+import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
+
+export default {
+ name: 'MembersTable',
+ components: {
+ GlTable,
+ GlBadge,
+ MemberAvatar,
+ CreatedAt,
+ ExpiresAt,
+ MembersTableCell,
+ MemberSource,
+ MemberActionButtons,
+ RoleDropdown,
+ RemoveGroupLinkModal,
+ },
+ computed: {
+ ...mapState(['members', 'tableFields']),
+ filteredFields() {
+ return FIELDS.filter(field => this.tableFields.includes(field.key));
+ },
+ },
+ mounted() {
+ initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-table
+ class="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
+ >
+ <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" :member="member" />
+ <gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge>
+ </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 />
+ </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
new file mode 100644
index 00000000000..5602978bb6c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue
@@ -0,0 +1,64 @@
+<script>
+import { mapState } from 'vuex';
+import { MEMBER_TYPES } from '../constants';
+
+export default {
+ name: 'MembersTableCell',
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['sourceId', 'currentUserId']),
+ isGroup() {
+ return Boolean(this.member.sharedWithGroup);
+ },
+ 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 this.isGroup || this.member.source?.id === this.sourceId;
+ },
+ isCurrentUser() {
+ return this.member.user?.id === this.currentUserId;
+ },
+ canRemove() {
+ return this.isDirectMember && this.member.canRemove;
+ },
+ canResend() {
+ return Boolean(this.member.invite?.canResend);
+ },
+ canUpdate() {
+ return !this.isCurrentUser && this.isDirectMember && this.member.canUpdate;
+ },
+ },
+ 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
new file mode 100644
index 00000000000..2b40ccc3a9d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue
@@ -0,0 +1,70 @@
+<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,
+ },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isDesktop: false,
+ busy: false,
+ };
+ },
+ mounted() {
+ this.isDesktop = bp.isDesktop();
+ },
+ 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
+ :right="!isDesktop"
+ :text="member.accessLevel.stringValue"
+ :header-text="__('Change permissions')"
+ :disabled="busy"
+ >
+ <gl-dropdown-item
+ v-for="(value, name) in member.validRoles"
+ :key="value"
+ is-check-item
+ :is-checked="value === member.accessLevel.integerValue"
+ @click="handleSelect(value, name)"
+ >
+ {{ name }}
+ </gl-dropdown-item>
+ </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
new file mode 100644
index 00000000000..782a0b7f96b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/utils.js
@@ -0,0 +1,19 @@
+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',
+ },
+];
diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
index 35ba7c665d5..cad4439ecea 100644
--- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
+++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
@@ -1,19 +1,16 @@
<script>
import $ from 'jquery';
-import { GlDeprecatedButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import Clipboard from 'clipboard';
import { __ } from '~/locale';
export default {
components: {
- GlDeprecatedButton,
- GlIcon,
+ GlButton,
},
-
directives: {
GlTooltip: GlTooltipDirective,
},
-
props: {
text: {
type: String,
@@ -55,15 +52,12 @@ export default {
default: null,
},
},
-
copySuccessText: __('Copied'),
-
computed: {
modalDomId() {
return this.modalId ? `#${this.modalId}` : '';
},
},
-
mounted() {
this.$nextTick(() => {
this.clipboard = new Clipboard(this.$el, {
@@ -83,13 +77,11 @@ export default {
.on('error', e => this.$emit('error', e));
});
},
-
destroyed() {
if (this.clipboard) {
this.clipboard.destroy();
}
},
-
methods: {
updateTooltip(target) {
const $target = $(target);
@@ -112,15 +104,12 @@ export default {
};
</script>
<template>
- <gl-deprecated-button
+ <gl-button
v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
:class="cssClasses"
:data-clipboard-target="target"
:data-clipboard-text="text"
:title="title"
- >
- <slot>
- <gl-icon name="copy-to-clipboard" />
- </slot>
- </gl-deprecated-button>
+ icon="copy-to-clipboard"
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
index f8983a3d29a..3749888ee36 100644
--- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
@@ -58,7 +58,12 @@ export default {
active: tab.isActive,
}"
>
- <a :class="`js-${scope}-tab-${tab.scope}`" role="button" @click="onTabClick(tab)">
+ <a
+ :class="`js-${scope}-tab-${tab.scope}`"
+ :data-testid="`${scope}-tab-${tab.scope}`"
+ role="button"
+ @click="onTabClick(tab)"
+ >
{{ tab.name }}
<span v-if="shouldRenderBadge(tab.count)" class="badge badge-pill"> {{ tab.count }} </span>
diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
index 53dbae39608..3aca068c074 100644
--- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
@@ -12,7 +12,7 @@ export default {
</script>
<template>
- <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note">
+ <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note_placeholder">
<div class="timeline-icon"></div>
<div class="timeline-content">
<div class="note-header"></div>
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js
new file mode 100644
index 00000000000..b7768cfa5b9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js
@@ -0,0 +1,21 @@
+import { __ } from '~/locale';
+
+export const tdClass =
+ 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap';
+export const thClass = 'gl-hover-bg-blue-50';
+export const bodyTrClass =
+ 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-b-solid gl-hover-border-blue-200';
+
+export const defaultPageSize = 20;
+
+export const initialPaginationState = {
+ page: 1,
+ prevPageCursor: '',
+ nextPageCursor: '',
+ firstPageSize: defaultPageSize,
+ lastPageSize: null,
+};
+
+export const defaultI18n = {
+ searchPlaceholder: __('Search or filter results…'),
+};
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
new file mode 100644
index 00000000000..8e85d93e6d1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
@@ -0,0 +1,313 @@
+<script>
+import { GlAlert, GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui';
+import Api from '~/api';
+import Tracking from '~/tracking';
+import { __ } from '~/locale';
+import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
+import { initialPaginationState, defaultI18n, defaultPageSize } from './constants';
+import { isAny } from './utils';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
+
+export default {
+ defaultI18n,
+ components: {
+ GlAlert,
+ GlBadge,
+ GlPagination,
+ GlTabs,
+ GlTab,
+ FilteredSearchBar,
+ },
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ textQuery: {
+ default: '',
+ },
+ assigneeUsernameQuery: {
+ default: '',
+ },
+ authorUsernameQuery: {
+ default: '',
+ },
+ },
+ props: {
+ items: {
+ type: Array,
+ required: true,
+ },
+ itemsCount: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ pageInfo: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ statusTabs: {
+ type: Array,
+ required: true,
+ },
+ showItems: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ showErrorMsg: {
+ type: Boolean,
+ required: true,
+ },
+ trackViewsOptions: {
+ type: Object,
+ required: true,
+ },
+ i18n: {
+ type: Object,
+ required: true,
+ },
+ serverErrorMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ filterSearchKey: {
+ type: String,
+ required: true,
+ },
+ filterSearchTokens: {
+ type: Array,
+ required: false,
+ default: () => ['author_username', 'assignee_username'],
+ },
+ },
+ data() {
+ return {
+ searchTerm: this.textQuery,
+ authorUsername: this.authorUsernameQuery,
+ assigneeUsername: this.assigneeUsernameQuery,
+ filterParams: {},
+ pagination: initialPaginationState,
+ filteredByStatus: '',
+ statusFilter: '',
+ };
+ },
+ computed: {
+ defaultTokens() {
+ return [
+ {
+ type: 'author_username',
+ icon: 'user',
+ title: __('Author'),
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: __('is'), default: 'true' }],
+ fetchPath: this.projectPath,
+ fetchAuthors: Api.projectUsers.bind(Api),
+ },
+ {
+ type: 'assignee_username',
+ icon: 'user',
+ title: __('Assignee'),
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: __('is'), default: 'true' }],
+ fetchPath: this.projectPath,
+ fetchAuthors: Api.projectUsers.bind(Api),
+ },
+ ];
+ },
+ filteredSearchTokens() {
+ return this.defaultTokens.filter(({ type }) => this.filterSearchTokens.includes(type));
+ },
+ filteredSearchValue() {
+ const value = [];
+
+ if (this.authorUsername) {
+ value.push({
+ type: 'author_username',
+ value: { data: this.authorUsername },
+ });
+ }
+
+ if (this.assigneeUsername) {
+ value.push({
+ type: 'assignee_username',
+ value: { data: this.assigneeUsername },
+ });
+ }
+
+ if (this.searchTerm) {
+ value.push(this.searchTerm);
+ }
+
+ return value;
+ },
+ itemsForCurrentTab() {
+ return this.itemsCount?.[this.filteredByStatus.toLowerCase()] ?? 0;
+ },
+ showPaginationControls() {
+ return Boolean(this.pageInfo?.hasNextPage || this.pageInfo?.hasPreviousPage);
+ },
+ previousPage() {
+ return Math.max(this.pagination.page - 1, 0);
+ },
+ nextPage() {
+ const nextPage = this.pagination.page + 1;
+ return nextPage > Math.ceil(this.itemsForCurrentTab / defaultPageSize) ? null : nextPage;
+ },
+ },
+ mounted() {
+ this.trackPageViews();
+ },
+ methods: {
+ filterItemsByStatus(tabIndex) {
+ this.resetPagination();
+ const { filters, status } = this.statusTabs[tabIndex];
+ this.statusFilter = filters;
+ this.filteredByStatus = status;
+
+ this.$emit('tabs-changed', { filters, status });
+ },
+ handlePageChange(page) {
+ const { startCursor, endCursor } = this.pageInfo;
+
+ if (page > this.pagination.page) {
+ this.pagination = {
+ ...initialPaginationState,
+ nextPageCursor: endCursor,
+ page,
+ };
+ } else {
+ this.pagination = {
+ lastPageSize: defaultPageSize,
+ firstPageSize: null,
+ prevPageCursor: startCursor,
+ nextPageCursor: '',
+ page,
+ };
+ }
+
+ this.$emit('page-changed', this.pagination);
+ },
+ resetPagination() {
+ this.pagination = initialPaginationState;
+ this.$emit('page-changed', this.pagination);
+ },
+ handleFilterItems(filters) {
+ this.resetPagination();
+ const filterParams = { authorUsername: '', assigneeUsername: '', search: '' };
+
+ filters.forEach(filter => {
+ if (typeof filter === 'object') {
+ switch (filter.type) {
+ case 'author_username':
+ filterParams.authorUsername = isAny(filter.value.data);
+ break;
+ case 'assignee_username':
+ filterParams.assigneeUsername = isAny(filter.value.data);
+ break;
+ case 'filtered-search-term':
+ if (filter.value.data !== '') filterParams.search = filter.value.data;
+ break;
+ default:
+ break;
+ }
+ }
+ });
+
+ this.filterParams = filterParams;
+ this.updateUrl();
+ this.searchTerm = filterParams?.search;
+ this.authorUsername = filterParams?.authorUsername;
+ this.assigneeUsername = filterParams?.assigneeUsername;
+
+ this.$emit('filters-changed', {
+ searchTerm: this.searchTerm,
+ authorUsername: this.authorUsername,
+ assigneeUsername: this.assigneeUsername,
+ });
+ },
+ updateUrl() {
+ const { authorUsername, assigneeUsername, search } = this.filterParams || {};
+
+ const params = {
+ ...(authorUsername !== '' && { author_username: authorUsername }),
+ ...(assigneeUsername !== '' && { assignee_username: assigneeUsername }),
+ ...(search !== '' && { search }),
+ };
+
+ updateHistory({
+ url: setUrlParams(params, window.location.href, true),
+ title: document.title,
+ replace: true,
+ });
+ },
+ trackPageViews() {
+ const { category, action } = this.trackViewsOptions;
+ Tracking.event(category, action);
+ },
+ },
+};
+</script>
+<template>
+ <div class="incident-management-list">
+ <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="$emit('error-alert-dismissed')">
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <p v-html="serverErrorMessage || i18n.errorMsg"></p>
+ </gl-alert>
+
+ <div
+ class="list-header gl-display-flex gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-gray-100"
+ >
+ <gl-tabs content-class="gl-p-0" @input="filterItemsByStatus">
+ <gl-tab v-for="tab in statusTabs" :key="tab.status" :data-testid="tab.status">
+ <template #title>
+ <span>{{ tab.title }}</span>
+ <gl-badge v-if="itemsCount" pill size="sm" class="gl-tab-counter-badge">
+ {{ itemsCount[tab.status.toLowerCase()] }}
+ </gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+
+ <slot name="header-actions"></slot>
+ </div>
+
+ <div class="filtered-search-wrapper">
+ <filtered-search-bar
+ :namespace="projectPath"
+ :search-input-placeholder="$options.defaultI18n.searchPlaceholder"
+ :tokens="filteredSearchTokens"
+ :initial-filter-value="filteredSearchValue"
+ initial-sortby="created_desc"
+ :recent-searches-storage-key="filterSearchKey"
+ class="row-content-block"
+ @onFilter="handleFilterItems"
+ />
+ </div>
+
+ <h4 class="gl-display-block d-md-none my-3">
+ <slot name="title"></slot>
+ </h4>
+
+ <slot v-if="showItems" name="table"></slot>
+
+ <gl-pagination
+ v-if="showPaginationControls"
+ :value="pagination.page"
+ :prev-page="previousPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-pagination gl-mt-3"
+ @input="handlePageChange"
+ />
+
+ <slot v-if="!showItems" name="emtpy-state"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js
new file mode 100644
index 00000000000..7de4263acbb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js
@@ -0,0 +1,11 @@
+import { __ } from '~/locale';
+
+/**
+ * Return a empty string when passed a value of 'Any'
+ *
+ * @param {String} value
+ * @returns {String}
+ */
+export const isAny = value => {
+ return value === __('Any') ? '' : value;
+};
diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
index 50a19dc2156..7046ac5be03 100644
--- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
@@ -39,7 +39,7 @@ export default {
},
},
mounted() {
- this.detailsSlots = Object.keys(this.$slots).filter(k => k.startsWith('details_'));
+ this.detailsSlots = Object.keys(this.$slots).filter(k => k.startsWith('details-'));
},
methods: {
toggleDetails() {
diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
index cc33b8f85cd..06b4309ad42 100644
--- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
@@ -1,10 +1,12 @@
<script>
-import { GlAvatar } from '@gitlab/ui';
+import { GlAvatar, GlSprintf, GlLink } from '@gitlab/ui';
export default {
name: 'TitleArea',
components: {
GlAvatar,
+ GlSprintf,
+ GlLink,
},
props: {
avatar: {
@@ -17,6 +19,11 @@ export default {
default: null,
required: false,
},
+ infoMessages: {
+ type: Array,
+ default: () => [],
+ required: false,
+ },
},
data() {
return {
@@ -24,43 +31,64 @@ export default {
};
},
mounted() {
- this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith('metadata_'));
+ this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith('metadata-'));
},
};
</script>
<template>
- <div class="gl-display-flex gl-justify-content-space-between gl-py-3">
- <div class="gl-flex-direction-column">
- <div class="gl-display-flex">
- <gl-avatar v-if="avatar" :src="avatar" shape="rect" class="gl-align-self-center gl-mr-4" />
+ <div class="gl-display-flex gl-flex-direction-column">
+ <div class="gl-display-flex gl-justify-content-space-between gl-py-3">
+ <div class="gl-flex-direction-column">
+ <div class="gl-display-flex">
+ <gl-avatar
+ v-if="avatar"
+ :src="avatar"
+ shape="rect"
+ class="gl-align-self-center gl-mr-4"
+ />
- <div class="gl-display-flex gl-flex-direction-column">
- <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2" data-testid="title">
- <slot name="title">{{ title }}</slot>
- </h1>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2" data-testid="title">
+ <slot name="title">{{ title }}</slot>
+ </h1>
+
+ <div
+ v-if="$slots['sub-header']"
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ >
+ <slot name="sub-header"></slot>
+ </div>
+ </div>
+ </div>
+ <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3">
<div
- v-if="$slots['sub-header']"
- class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ v-for="(row, metadataIndex) in metadataSlots"
+ :key="metadataIndex"
+ class="gl-display-flex gl-align-items-center gl-mr-5"
>
- <slot name="sub-header"></slot>
+ <slot :name="row"></slot>
</div>
</div>
</div>
-
- <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3">
- <div
- v-for="(row, metadataIndex) in metadataSlots"
- :key="metadataIndex"
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <slot :name="row"></slot>
- </div>
+ <div v-if="$slots['right-actions']" class="gl-mt-3">
+ <slot name="right-actions"></slot>
</div>
</div>
- <div v-if="$slots['right-actions']" class="gl-mt-3">
- <slot name="right-actions"></slot>
- </div>
+ <p>
+ <span
+ v-for="(message, index) in infoMessages"
+ :key="index"
+ class="gl-mr-2"
+ data-testid="info-message"
+ >
+ <gl-sprintf :message="message.text">
+ <template #docLink="{content}">
+ <gl-link :href="message.link" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </p>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
index c08659919fa..cbb30baa488 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
@@ -2,8 +2,15 @@ import { __ } from '~/locale';
export const CUSTOM_EVENTS = {
openAddImageModal: 'gl_openAddImageModal',
+ openInsertVideoModal: 'gl_openInsertVideoModal',
};
+export const YOUTUBE_URL = 'https://www.youtube.com';
+
+export const YOUTUBE_EMBED_URL = `${YOUTUBE_URL}/embed`;
+
+export const ALLOWED_VIDEO_ORIGINS = [YOUTUBE_URL];
+
/* eslint-disable @gitlab/require-i18n-strings */
export const TOOLBAR_ITEM_CONFIGS = [
{ icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') },
@@ -23,6 +30,7 @@ export const TOOLBAR_ITEM_CONFIGS = [
{ icon: 'dash', command: 'HR', tooltip: __('Add a line') },
{ icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') },
{ icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') },
+ { icon: 'live-preview', event: CUSTOM_EVENTS.openInsertVideoModal, tooltip: __('Insert video') },
{ isDivider: true },
{ icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
{ icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
@@ -40,3 +48,10 @@ export const EDITOR_PREVIEW_STYLE = 'horizontal';
export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 };
export const MAX_FILE_SIZE = 2097152; // 2Mb
+
+export const VIDEO_ATTRIBUTES = {
+ width: '560',
+ height: '315',
+ frameBorder: '0',
+ allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
+};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
index 429a4e04110..e1652f54982 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
@@ -32,8 +32,8 @@ export default {
uploadImageTab: null,
};
},
- modalTitle: __('Image Details'),
- okTitle: __('Insert'),
+ modalTitle: __('Image details'),
+ okTitle: __('Insert image'),
urlTabTitle: __('By URL'),
urlLabel: __('Image URL'),
descriptionLabel: __('Description'),
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue
new file mode 100644
index 00000000000..99bb2080610
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui';
+import { isSafeURL } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import { YOUTUBE_URL, YOUTUBE_EMBED_URL } from '../constants';
+
+export default {
+ components: {
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlSprintf,
+ },
+ data() {
+ return {
+ url: null,
+ urlError: null,
+ description: __(
+ 'If the YouTube URL is https://www.youtube.com/watch?v=0t1DgySidms then the video ID is %{id}',
+ ),
+ };
+ },
+ modalTitle: __('Insert a video'),
+ okTitle: __('Insert video'),
+ label: __('YouTube URL or ID'),
+ methods: {
+ show() {
+ this.urlError = null;
+ this.url = null;
+
+ this.$refs.modal.show();
+ },
+ onPrimary(event) {
+ this.submitURL(event);
+ },
+ submitURL(event) {
+ const url = this.generateUrl();
+
+ if (!url) {
+ event.preventDefault();
+ return;
+ }
+
+ this.$emit('insertVideo', url);
+ },
+ generateUrl() {
+ let { url } = this;
+ const reYouTubeId = /^[A-z0-9]*$/;
+ const reYouTubeUrl = RegExp(`${YOUTUBE_URL}/(embed/|watch\\?v=)([A-z0-9]+)`);
+
+ if (reYouTubeId.test(url)) {
+ url = `${YOUTUBE_EMBED_URL}/${url}`;
+ } else if (reYouTubeUrl.test(url)) {
+ url = `${YOUTUBE_EMBED_URL}/${reYouTubeUrl.exec(url)[2]}`;
+ }
+
+ if (!isSafeURL(url) || !reYouTubeUrl.test(url)) {
+ this.urlError = __('Please provide a valid YouTube URL or ID');
+ this.$refs.urlInput.$el.focus();
+ return null;
+ }
+
+ return url;
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ size="sm"
+ modal-id="insert-video-modal"
+ :title="$options.modalTitle"
+ :ok-title="$options.okTitle"
+ @primary="onPrimary"
+ >
+ <gl-form-group
+ :label="$options.label"
+ label-for="video-modal-url-input"
+ :state="!Boolean(urlError)"
+ :invalid-feedback="urlError"
+ >
+ <gl-form-input id="video-modal-url-input" ref="urlInput" v-model="url" />
+ <gl-sprintf slot="description" :message="description" class="text-gl-muted">
+ <template #id>
+ <strong>{{ __('0t1DgySidms') }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-form-group>
+ </gl-modal>
+</template>
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 d96fe46522e..c2518441506 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
@@ -3,6 +3,7 @@ import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css';
import AddImageModal from './modals/add_image/add_image_modal.vue';
+import InsertVideoModal from './modals/insert_video_modal.vue';
import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS } from './constants';
import {
@@ -12,6 +13,7 @@ import {
removeCustomEventListener,
addImage,
getMarkdown,
+ insertVideo,
} from './services/editor_service';
export default {
@@ -21,6 +23,7 @@ export default {
toast => toast.Editor,
),
AddImageModal,
+ InsertVideoModal,
},
props: {
content: {
@@ -63,6 +66,12 @@ export default {
editorInstance() {
return this.$refs.editor;
},
+ customEventListeners() {
+ return [
+ { event: CUSTOM_EVENTS.openAddImageModal, listener: this.onOpenAddImageModal },
+ { event: CUSTOM_EVENTS.openInsertVideoModal, listener: this.onOpenInsertVideoModal },
+ ];
+ },
},
created() {
this.editorOptions = getEditorOptions(this.options);
@@ -72,16 +81,16 @@ export default {
},
methods: {
addListeners(editorApi) {
- addCustomEventListener(editorApi, CUSTOM_EVENTS.openAddImageModal, this.onOpenAddImageModal);
+ this.customEventListeners.forEach(({ event, listener }) => {
+ addCustomEventListener(editorApi, event, listener);
+ });
editorApi.eventManager.listen('changeMode', this.onChangeMode);
},
removeListeners() {
- removeCustomEventListener(
- this.editorApi,
- CUSTOM_EVENTS.openAddImageModal,
- this.onOpenAddImageModal,
- );
+ this.customEventListeners.forEach(({ event, listener }) => {
+ removeCustomEventListener(this.editorApi, event, listener);
+ });
this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
},
@@ -111,6 +120,12 @@ export default {
addImage(this.editorInstance, image);
},
+ onOpenInsertVideoModal() {
+ this.$refs.insertVideoModal.show();
+ },
+ onInsertVideo(url) {
+ insertVideo(this.editorInstance, url);
+ },
onChangeMode(newMode) {
this.$emit('modeChange', newMode);
},
@@ -130,5 +145,6 @@ export default {
@load="onLoad"
/>
<add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" />
+ <insert-video-modal ref="insertVideoModal" @insertVideo="onInsertVideo" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
index 51ba033dff0..8b3fbcabcfa 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
@@ -3,7 +3,8 @@ import { defaults } from 'lodash';
import ToolbarItem from '../toolbar_item.vue';
import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer';
import buildCustomHTMLRenderer from './build_custom_renderer';
-import { TOOLBAR_ITEM_CONFIGS } from '../constants';
+import { TOOLBAR_ITEM_CONFIGS, VIDEO_ATTRIBUTES } from '../constants';
+import sanitizeHTML from './sanitize_html';
const buildWrapper = propsData => {
const instance = new Vue({
@@ -16,6 +17,23 @@ const buildWrapper = propsData => {
return instance.$el;
};
+const buildVideoIframe = src => {
+ const wrapper = document.createElement('figure');
+ const iframe = document.createElement('iframe');
+ const videoAttributes = { ...VIDEO_ATTRIBUTES, src };
+ const wrapperClasses = ['gl-relative', 'gl-h-0', 'video_container'];
+ const iframeClasses = ['gl-absolute', 'gl-top-0', 'gl-left-0', 'gl-w-full', 'gl-h-full'];
+
+ wrapper.setAttribute('contenteditable', 'false');
+ wrapper.classList.add(...wrapperClasses);
+ iframe.classList.add(...iframeClasses);
+ Object.assign(iframe, videoAttributes);
+
+ wrapper.appendChild(iframe);
+
+ return wrapper;
+};
+
export const generateToolbarItem = config => {
const { icon, classes, event, command, tooltip, isDivider } = config;
@@ -43,6 +61,16 @@ export const removeCustomEventListener = (editorApi, event, handler) =>
export const addImage = ({ editor }, image) => editor.exec('AddImage', image);
+export const insertVideo = ({ editor }, url) => {
+ const videoIframe = buildVideoIframe(url);
+
+ if (editor.isWysiwygMode()) {
+ editor.getSquire().insertElement(videoIframe);
+ } else {
+ editor.insertText(videoIframe.outerHTML);
+ }
+};
+
export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown');
/**
@@ -62,5 +90,6 @@ export const getEditorOptions = externalOptions => {
return defaults({
customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers),
toolbarItems: TOOLBAR_ITEM_CONFIGS.map(toolbarItem => generateToolbarItem(toolbarItem)),
+ customHTMLSanitizer: html => sanitizeHTML(html),
});
};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
index b179ca61dba..18bd17d43d9 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
@@ -1,7 +1,21 @@
import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token';
+import { ALLOWED_VIDEO_ORIGINS } from '../../constants';
+import { getURLOrigin } from '~/lib/utils/url_utility';
-const canRender = ({ type }) => {
- return type === 'htmlBlock';
+const isVideoFrame = html => {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+ const {
+ children: { length },
+ } = doc;
+ const iframe = doc.querySelector('iframe');
+ const origin = iframe && getURLOrigin(iframe.getAttribute('src'));
+
+ return length === 1 && ALLOWED_VIDEO_ORIGINS.includes(origin);
+};
+
+const canRender = ({ type, literal }) => {
+ return type === 'htmlBlock' && !isVideoFrame(literal);
};
const render = node => buildUneditableHtmlAsTextTokens(node);
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js
new file mode 100644
index 00000000000..eae2e0335c1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js
@@ -0,0 +1,22 @@
+import createSanitizer from 'dompurify';
+import { ALLOWED_VIDEO_ORIGINS } from '../constants';
+import { getURLOrigin } from '~/lib/utils/url_utility';
+
+const sanitizer = createSanitizer(window);
+const ADD_TAGS = ['iframe'];
+
+sanitizer.addHook('uponSanitizeElement', node => {
+ if (node.tagName !== 'IFRAME') {
+ return;
+ }
+
+ const origin = getURLOrigin(node.getAttribute('src'));
+
+ if (!ALLOWED_VIDEO_ORIGINS.includes(origin)) {
+ node.remove();
+ }
+});
+
+const sanitize = content => sanitizer.sanitize(content, { ADD_TAGS });
+
+export default sanitize;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
index 0ed5a050fe4..6511c8d8c31 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
@@ -1,11 +1,10 @@
<script>
-import { GlIcon } from '@gitlab/ui';
-import tooltip from '~/vue_shared/directives/tooltip';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
name: 'CollapsedCalendarIcon',
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@@ -41,16 +40,7 @@ export default {
</script>
<template>
- <div
- v-tooltip
- :class="containerClass"
- :title="tooltipText"
- data-container="body"
- data-placement="left"
- data-html="true"
- data-boundary="viewport"
- @click="click"
- >
+ <div v-gl-tooltip.left.viewport :class="containerClass" :title="tooltipText" @click="click">
<gl-icon v-if="showIcon" name="calendar" />
<slot>
<span> {{ text }} </span>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
index 6839354fb3a..267c3be5f50 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
@@ -38,6 +38,7 @@ export default {
<template>
<div
class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute"
+ data-qa-selector="labels_dropdown_content"
:style="directionStyle"
>
<component :is="dropdownContentsView" />
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
index 0b763aa4b72..c8dee81d746 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
@@ -1,6 +1,7 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink } from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
@@ -39,9 +40,9 @@ export default {
...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
visibleLabels() {
if (this.searchKey) {
- return this.labels.filter(label =>
- label.title.toLowerCase().includes(this.searchKey.toLowerCase()),
- );
+ return fuzzaldrinPlus.filter(this.labels, this.searchKey, {
+ key: ['title'],
+ });
}
return this.labels;
},
@@ -112,6 +113,7 @@ export default {
this.currentHighlightItem += 1;
} else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) {
this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]);
+ this.searchKey = '';
} else if (e.keyCode === ESC_KEY_CODE) {
this.toggleDropdownContents();
}
@@ -155,7 +157,11 @@ export default {
/>
</div>
<div class="dropdown-input" @click.stop="() => {}">
- <gl-search-box-by-type v-model="searchKey" :autofocus="true" />
+ <gl-search-box-by-type
+ v-model="searchKey"
+ :autofocus="true"
+ data-qa-selector="dropdown_input_field"
+ />
</div>
<div
v-show="showListContainer"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
index 12ad2acf308..a6f99289df4 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
@@ -8,8 +8,20 @@ export default {
components: {
GlLabel,
},
+ props: {
+ disableLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
computed: {
- ...mapState(['selectedLabels', 'allowScopedLabels', 'labelsFilterBasePath']),
+ ...mapState([
+ 'selectedLabels',
+ 'allowLabelRemove',
+ 'allowScopedLabels',
+ 'labelsFilterBasePath',
+ ]),
},
methods: {
labelFilterUrl(label) {
@@ -35,12 +47,17 @@ export default {
<template v-for="label in selectedLabels" v-else>
<gl-label
:key="label.id"
+ data-qa-selector="selected_label_content"
+ :data-qa-label-name="label.title"
:title="label.title"
:description="label.description"
:background-color="label.color"
:target="labelFilterUrl(label)"
:scoped="scopedLabel(label)"
+ :show-close-button="allowLabelRemove"
+ :disabled="disableLabels"
tooltip-placement="top"
+ @close="$emit('onLabelRemove', label.id)"
/>
</template>
</div>
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 34f5517ef99..c651013c5f5 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
@@ -28,6 +28,11 @@ export default {
DropdownValueCollapsed,
},
props: {
+ allowLabelRemove: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
allowLabelEdit: {
type: Boolean,
required: true,
@@ -130,6 +135,7 @@ export default {
mounted() {
this.setInitialState({
variant: this.variant,
+ allowLabelRemove: this.allowLabelRemove,
allowLabelEdit: this.allowLabelEdit,
allowLabelCreate: this.allowLabelCreate,
allowMultiselect: this.allowMultiselect,
@@ -252,7 +258,10 @@ export default {
:allow-label-edit="allowLabelEdit"
:labels-select-in-progress="labelsSelectInProgress"
/>
- <dropdown-value>
+ <dropdown-value
+ :disable-labels="labelsSelectInProgress"
+ @onLabelRemove="$emit('onLabelRemove', $event)"
+ >
<slot></slot>
</dropdown-value>
<dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" />
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
index 2d236566b3d..e624bd1eaee 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
@@ -54,8 +54,5 @@ export const createLabel = ({ state, dispatch }, label) => {
});
};
-export const replaceSelectedLabels = ({ commit }, selectedLabels) =>
- commit(types.REPLACE_SELECTED_LABELS, selectedLabels);
-
export const updateSelectedLabels = ({ commit }, labels) =>
commit(types.UPDATE_SELECTED_LABELS, { labels });
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js
index af92665d4eb..2e044dc3b3c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js
@@ -15,7 +15,6 @@ export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE';
export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
-export const REPLACE_SELECTED_LABELS = 'REPLACE_SELECTED_LABELS';
export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS';
export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW';
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
index 7edd290a819..54f8c78b4e1 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
@@ -57,10 +57,6 @@ export default {
state.labelCreateInProgress = false;
},
- [types.REPLACE_SELECTED_LABELS](state, selectedLabels = []) {
- state.selectedLabels = selectedLabels;
- },
-
[types.UPDATE_SELECTED_LABELS](state, { labels }) {
// Find the label to update from all the labels
// and change `set` prop value to represent their current state.
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
index 3f3358d4805..d66cfed4163 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
@@ -15,6 +15,7 @@ export default () => ({
// UI Flags
variant: '',
+ allowLabelRemove: false,
allowLabelCreate: false,
allowLabelEdit: false,
allowScopedLabels: false,
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
index 040a15406e0..6dacf4e10d3 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
@@ -1,11 +1,14 @@
<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
export default {
name: 'ToggleSidebar',
+ components: {
+ GlButton,
+ },
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
collapsed: {
@@ -22,6 +25,12 @@ export default {
tooltipLabel() {
return this.collapsed ? __('Expand sidebar') : __('Collapse sidebar');
},
+ buttonIcon() {
+ return this.collapsed ? 'chevron-double-lg-left' : 'chevron-double-lg-right';
+ },
+ allCssClasses() {
+ return [this.cssClasses, { 'js-sidebar-collapsed': this.collapsed }];
+ },
},
methods: {
toggle() {
@@ -32,25 +41,15 @@ export default {
</script>
<template>
- <button
- v-tooltip
+ <gl-button
+ v-gl-tooltip:body.viewport.left
:title="tooltipLabel"
- :class="cssClasses"
- type="button"
- class="btn btn-blank gutter-toggle btn-sidebar-action js-sidebar-vue-toggle"
- data-container="body"
- data-placement="left"
- data-boundary="viewport"
+ :class="allCssClasses"
+ class="gutter-toggle btn-sidebar-action js-sidebar-vue-toggle"
+ :icon="buttonIcon"
+ category="tertiary"
+ size="small"
+ :aria-label="__('toggle collapse')"
@click="toggle"
- >
- <i
- :class="{
- 'fa-angle-double-right': !collapsed,
- 'fa-angle-double-left': collapsed,
- }"
- :aria-label="__('toggle collapse')"
- class="fa"
- >
- </i>
- </button>
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue
index e9b99c6ea78..11049028ff6 100644
--- a/app/assets/javascripts/vue_shared/components/split_button.vue
+++ b/app/assets/javascripts/vue_shared/components/split_button.vue
@@ -1,19 +1,15 @@
<script>
import { isString } from 'lodash';
-import {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownItem,
-} from '@gitlab/ui';
+import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui';
const isValidItem = item =>
isString(item.eventName) && isString(item.title) && isString(item.description);
export default {
components: {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
},
props: {
@@ -32,7 +28,7 @@ export default {
variant: {
type: String,
required: false,
- default: 'secondary',
+ default: 'default',
},
},
@@ -61,8 +57,8 @@ export default {
</script>
<template>
- <gl-deprecated-dropdown
- :menu-class="`dropdown-menu-selectable ${menuClass}`"
+ <gl-dropdown
+ :menu-class="menuClass"
split
:text="dropdownToggleText"
:variant="variant"
@@ -70,20 +66,20 @@ export default {
@click="triggerEvent"
>
<template v-for="(item, itemIndex) in actionItems">
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
:key="item.eventName"
- :active="selectedItem === item"
- active-class="is-active"
+ :is-check-item="true"
+ :is-checked="selectedItem === item"
@click="changeSelectedItem(item)"
>
<strong>{{ item.title }}</strong>
<div>{{ item.description }}</div>
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
- <gl-deprecated-dropdown-divider
+ <gl-dropdown-divider
v-if="itemIndex < actionItems.length - 1"
:key="`${item.eventName}-divider`"
/>
</template>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
index a0c161a335a..f2e9c4a4fbb 100644
--- a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
+++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
@@ -1,11 +1,11 @@
<script>
+import { GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import { roundOffFloat } from '~/lib/utils/common_utils';
-import tooltip from '~/vue_shared/directives/tooltip';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
cssClass: {
@@ -112,7 +112,7 @@ export default {
<span v-if="!totalCount" class="status-unavailable">{{ unavailableLabel }}</span>
<span
v-if="successPercent"
- v-tooltip
+ v-gl-tooltip
:title="successTooltip"
:style="successBarStyle"
class="status-green"
@@ -122,7 +122,7 @@ export default {
</span>
<span
v-if="neutralPercent"
- v-tooltip
+ v-gl-tooltip
:title="neutralTooltip"
:style="neutralBarStyle"
class="status-neutral"
@@ -132,7 +132,7 @@ export default {
</span>
<span
v-if="failurePercent"
- v-tooltip
+ v-gl-tooltip
:title="failureTooltip"
:style="failureBarStyle"
class="status-red"
diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
index 135b9842cbf..f6721f5a27b 100644
--- a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDeprecatedDropdownItem, GlSearchBoxByType, GlIcon } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { __ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
@@ -7,9 +7,8 @@ export default {
name: 'TimezoneDropdown',
components: {
GlDropdown,
- GlDeprecatedDropdownItem,
+ GlDropdownItem,
GlSearchBoxByType,
- GlIcon,
},
directives: {
autofocusonshow,
@@ -74,29 +73,23 @@ export default {
};
</script>
<template>
- <gl-dropdown :text="value" block lazy menu-class="gl-w-full!">
- <template #button-content>
- <span class="gl-flex-grow-1" :class="{ 'gl-text-gray-300': !value }">
- {{ selectedTimezoneLabel }}
- </span>
- <gl-icon name="chevron-down" />
- </template>
-
- <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus class="gl-m-3" />
- <gl-deprecated-dropdown-item
+ <gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!">
+ <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus />
+ <gl-dropdown-item
v-for="timezone in filteredResults"
:key="timezone.formattedTimezone"
+ :is-checked="isSelected(timezone)"
+ :is-check-item="true"
@click="selectTimezone(timezone)"
>
- <gl-icon
- :class="{ invisible: !isSelected(timezone) }"
- name="mobile-issue-close"
- class="gl-vertical-align-middle"
- />
{{ timezone.formattedTimezone }}
- </gl-deprecated-dropdown-item>
- <gl-deprecated-dropdown-item v-if="!filteredResults.length" data-testid="noMatchingResults">
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="!filteredResults.length"
+ class="gl-pointer-events-none"
+ data-testid="noMatchingResults"
+ >
{{ $options.tranlations.noResultsText }}
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/todo_button.vue b/app/assets/javascripts/vue_shared/components/todo_button.vue
index debf19ccca6..a9d4f8403fa 100644
--- a/app/assets/javascripts/vue_shared/components/todo_button.vue
+++ b/app/assets/javascripts/vue_shared/components/todo_button.vue
@@ -15,7 +15,7 @@ export default {
},
computed: {
buttonLabel() {
- return this.isTodo ? __('Mark as done') : __('Add a To-Do');
+ return this.isTodo ? __('Mark as done') : __('Add a To Do');
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue
index 29d4516bece..861661d3519 100644
--- a/app/assets/javascripts/vue_shared/components/toggle_button.vue
+++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue
@@ -59,7 +59,7 @@ export default {
</script>
<template>
- <label class="toggle-wrapper">
+ <label class="gl-mt-2">
<input v-if="name" :name="name" :value="value" type="hidden" />
<button
type="button"
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 6aaff000845..3f5738b2b93 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -1,16 +1,27 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlPopover, GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlIcon } from '@gitlab/ui';
+import {
+ GlPopover,
+ GlLink,
+ GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlIcon,
+} from '@gitlab/ui';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji';
const MAX_SKELETON_LINES = 4;
+const SECURITY_BOT_USER_DATA = {
+ username: 'GitLab-Security-Bot',
+ name: 'GitLab Security Bot',
+};
+
export default {
name: 'UserPopover',
maxSkeletonLines: MAX_SKELETON_LINES,
components: {
GlIcon,
+ GlLink,
GlPopover,
GlSkeletonLoading,
UserAvatarImage,
@@ -43,6 +54,15 @@ export default {
userIsLoading() {
return !this.user?.loaded;
},
+ isSecurityBot() {
+ const { username, name, websiteUrl = '' } = this.user;
+ return (
+ gon.features?.securityAutoFix &&
+ username === SECURITY_BOT_USER_DATA.username &&
+ name === SECURITY_BOT_USER_DATA.name &&
+ websiteUrl.length
+ );
+ },
},
};
</script>
@@ -89,6 +109,12 @@ export default {
<div v-if="statusHtml" class="js-user-status gl-mt-3">
<span v-html="statusHtml"></span>
</div>
+ <div v-if="isSecurityBot" class="gl-text-blue-500">
+ <gl-icon name="question" />
+ <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
+ {{ sprintf(__('Learn more about %{username}'), { username: user.name }) }}
+ </gl-link>
+ </div>
</template>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 8307c6d3b55..877414519f7 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -4,6 +4,7 @@ import { __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
+const KEY_EDIT = 'edit';
const KEY_WEB_IDE = 'webide';
const KEY_GITPOD = 'gitpod';
@@ -13,15 +14,31 @@ export default {
LocalStorageSync,
},
props: {
- webIdeUrl: {
- type: String,
- required: true,
+ isFork: {
+ type: Boolean,
+ required: false,
+ default: false,
},
needsToFork: {
type: Boolean,
required: false,
default: false,
},
+ gitpodEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isBlob: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showEditButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
showWebIdeButton: {
type: Boolean,
required: false,
@@ -32,15 +49,20 @@ export default {
required: false,
default: false,
},
- gitpodUrl: {
+ editUrl: {
type: String,
required: false,
default: '',
},
- gitpodEnabled: {
- type: Boolean,
+ webIdeUrl: {
+ type: String,
required: false,
- default: false,
+ default: '',
+ },
+ gitpodUrl: {
+ type: String,
+ required: false,
+ default: '',
},
},
data() {
@@ -50,7 +72,33 @@ export default {
},
computed: {
actions() {
- return [this.webIdeAction, this.gitpodAction].filter(x => x);
+ return [this.webIdeAction, this.editAction, this.gitpodAction].filter(action => action);
+ },
+ editAction() {
+ if (!this.showEditButton) {
+ return null;
+ }
+
+ const handleOptions = this.needsToFork
+ ? {
+ href: '#modal-confirm-fork-edit',
+ handle: () => this.showModal('#modal-confirm-fork-edit'),
+ }
+ : { href: this.editUrl };
+
+ return {
+ key: KEY_EDIT,
+ text: __('Edit'),
+ secondaryText: __('Edit this file only.'),
+ tooltip: '',
+ attrs: {
+ 'data-qa-selector': 'edit_button',
+ 'data-track-event': 'click_edit',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ 'data-track-label': 'Edit',
+ },
+ ...handleOptions,
+ };
},
webIdeAction() {
if (!this.showWebIdeButton) {
@@ -58,16 +106,30 @@ export default {
}
const handleOptions = this.needsToFork
- ? { href: '#modal-confirm-fork', handle: () => this.showModal('#modal-confirm-fork') }
+ ? {
+ href: '#modal-confirm-fork-webide',
+ handle: () => this.showModal('#modal-confirm-fork-webide'),
+ }
: { href: this.webIdeUrl };
+ let text = __('Web IDE');
+
+ if (this.isBlob) {
+ text = __('Edit in Web IDE');
+ } else if (this.isFork) {
+ text = __('Edit fork in Web IDE');
+ }
+
return {
key: KEY_WEB_IDE,
- text: __('Web IDE'),
+ text,
secondaryText: __('Quickly and easily edit multiple files in your project.'),
tooltip: '',
attrs: {
'data-qa-selector': 'web_ide_button',
+ 'data-track-event': 'click_edit_ide',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ 'data-track-label': 'Web IDE',
},
...handleOptions,
};
@@ -107,8 +169,14 @@ export default {
</script>
<template>
- <div>
- <actions-button :actions="actions" :selected-key="selection" @select="select" />
+ <div class="d-inline-block gl-ml-3">
+ <actions-button
+ :actions="actions"
+ :selected-key="selection"
+ :variant="isBlob ? 'info' : 'default'"
+ :category="isBlob ? 'primary' : 'secondary'"
+ @select="select"
+ />
<local-storage-sync
storage-key="gl-web-ide-button-selected"
:value="selection"