summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/vue_shared')
-rw-r--r--app/assets/javascripts/vue_shared/components/actions_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/alert_details_table.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_modal.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row_header.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue36
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_modal.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/integrations_help_text.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/local_storage_sync.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/members/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue99
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table.vue56
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/members/utils.js29
-rw-r--r--app/assets/javascripts/vue_shared/components/modal_copy_button.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/title_area.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue25
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js23
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql20
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql16
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue220
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue211
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql13
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql17
-rw-r--r--app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/constants.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue172
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/utils.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue2
-rw-r--r--app/assets/javascripts/vue_shared/directives/validation.js132
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue26
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js24
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js10
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js31
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js16
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js24
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js10
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js30
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js16
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/utils.js75
64 files changed, 1592 insertions, 151 deletions
diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue
index 9b21de19185..cb4c5f20377 100644
--- a/app/assets/javascripts/vue_shared/components/actions_button.vue
+++ b/app/assets/javascripts/vue_shared/components/actions_button.vue
@@ -61,7 +61,6 @@ export default {
<gl-dropdown
v-if="hasMultipleActions"
v-gl-tooltip="selectedAction.tooltip"
- class="gl-button-deprecated-adapter"
:text="selectedAction.text"
:split-href="selectedAction.href"
:variant="variant"
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 34f6d384f7b..3e2b4cd35ab 100644
--- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue
+++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
@@ -7,7 +7,6 @@ import {
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!';
@@ -25,6 +24,7 @@ const allowedFields = [
'endedAt',
'details',
'hosts',
+ 'environment',
];
export default {
@@ -32,7 +32,6 @@ export default {
GlLoadingIcon,
GlTable,
},
- mixins: [glFeatureFlagsMixin()],
props: {
alert: {
type: Object,
@@ -60,9 +59,6 @@ export default {
},
],
computed: {
- flaggedAllowedFields() {
- return this.shouldDisplayEnvironment ? [...allowedFields, 'environment'] : allowedFields;
- },
items() {
if (!this.alert) {
return [];
@@ -84,13 +80,10 @@ export default {
[],
);
},
- shouldDisplayEnvironment() {
- return this.glFeatures.exposeEnvironmentPathInAlertDetails;
- },
},
methods: {
isAllowed(fieldName) {
- return this.flaggedAllowedFields.includes(fieldName);
+ return allowedFields.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 2e4b9b9a135..7a687ea4ad0 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -1,8 +1,7 @@
<script>
/* eslint-disable vue/no-v-html */
import { groupBy } from 'lodash';
-import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
-import tooltip from '~/vue_shared/directives/tooltip';
+import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { glEmojiTag } from '../../emoji';
import { __, sprintf } from '~/locale';
@@ -15,7 +14,7 @@ export default {
GlLoadingIcon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
awards: {
@@ -154,10 +153,9 @@ export default {
<button
v-for="awardList in groupedAwards"
:key="awardList.name"
- v-tooltip
+ v-gl-tooltip.viewport
:class="awardList.classes"
:title="awardList.title"
- data-boundary="viewport"
data-testid="award-button"
class="btn award-control"
type="button"
@@ -168,12 +166,11 @@ export default {
</button>
<div v-if="canAwardEmoji" class="award-menu-holder">
<button
- v-tooltip
+ v-gl-tooltip.viewport
:class="addButtonClass"
class="award-control btn js-add-award"
title="Add reaction"
:aria-label="__('Add reaction')"
- data-boundary="viewport"
type="button"
>
<span class="award-control-icon award-control-icon-neutral">
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 7a76888c916..6f7723955bf 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
@@ -1,4 +1,4 @@
-import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance_constants';
+import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants';
import eventHub from '~/blob/components/eventhub';
export default {
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index bbe72a2b122..646e1703f1e 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -9,6 +9,7 @@ export default {
GlIcon,
},
mixins: [ViewerMixin],
+ inject: ['blobHash'],
data() {
return {
highlightedLine: null,
@@ -64,7 +65,7 @@ export default {
</a>
</div>
<div class="blob-content">
- <pre class="code highlight"><code id="blob-code-content" v-html="content"></code></pre>
+ <pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index ff665d9cc58..d775a093f5f 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -6,11 +6,8 @@ import { GlIcon } from '@gitlab/ui';
*
* Receives status object containing:
* status: {
- * details_path: "/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
- * text:"running" // text rendered
* }
*
* Used in:
diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
index a42a606d446..96f800511d2 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
@@ -1,5 +1,5 @@
<script>
-import { GlModal } from '@gitlab/ui';
+import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import csrf from '~/lib/utils/csrf';
@@ -7,6 +7,9 @@ export default {
components: {
GlModal,
},
+ directives: {
+ SafeHtml,
+ },
props: {
selector: {
type: String,
@@ -71,7 +74,8 @@ export default {
-->
<input type="hidden" name="_method" :value="method" />
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
- <div>{{ modalAttributes.message }}</div>
+ <div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div>
+ <div v-else>{{ modalAttributes.message }}</div>
</form>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
index 494df2d7a37..7e82d8f3f9c 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
@@ -1,10 +1,11 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlLoadingIcon,
+ GlIcon,
},
props: {
isDisabled: {
@@ -39,8 +40,10 @@ export default {
<slot v-if="$slots.default"></slot>
<span v-else class="dropdown-toggle-text"> {{ toggleText }} </span>
</template>
- <span v-show="!isLoading" class="dropdown-toggle-icon">
- <i class="fa fa-chevron-down" aria-hidden="true" data-hidden="true"></i>
- </span>
+ <gl-icon
+ v-show="!isLoading"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
+ name="chevron-down"
+ />
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index c1c4f437dee..b4115b0c6a4 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -1,4 +1,5 @@
<script>
+import { GlTruncate } from '@gitlab/ui';
import FileHeader from '~/vue_shared/components/file_row_header.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { escapeFileUrl } from '~/lib/utils/url_utility';
@@ -8,6 +9,7 @@ export default {
components: {
FileHeader,
FileIcon,
+ GlTruncate,
},
props: {
file: {
@@ -28,6 +30,11 @@ export default {
required: false,
default: '',
},
+ truncateMiddle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
isTree() {
@@ -134,9 +141,9 @@ export default {
<span
ref="textOutput"
:style="levelIndentation"
- class="file-row-name str-truncated"
+ class="file-row-name"
data-qa-selector="file_name_content"
- :class="fileClasses"
+ :class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]"
>
<file-icon
class="file-row-icon"
@@ -146,8 +153,10 @@ export default {
:folder="isTree"
:opened="file.opened"
:size="16"
+ :submodule="file.submodule"
/>
- {{ file.name }}
+ <gl-truncate v-if="truncateMiddle" :text="file.name" position="middle" class="gl-pr-7" />
+ <template v-else>{{ file.name }}</template>
</span>
<slot></slot>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/file_row_header.vue b/app/assets/javascripts/vue_shared/components/file_row_header.vue
index 2c3e2a3a433..5afb2408c7e 100644
--- a/app/assets/javascripts/vue_shared/components/file_row_header.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row_header.vue
@@ -1,25 +1,21 @@
<script>
-import { truncatePathMiddleToLength } from '~/lib/utils/text_utility';
-
-const MAX_PATH_LENGTH = 40;
+import { GlTruncate } from '@gitlab/ui';
export default {
+ components: {
+ GlTruncate,
+ },
props: {
path: {
type: String,
required: true,
},
},
- computed: {
- truncatedPath() {
- return truncatePathMiddleToLength(this.path, MAX_PATH_LENGTH);
- },
- },
};
</script>
<template>
<div class="file-row-header bg-white sticky-top p-2 js-file-row-header" :title="path">
- <span class="bold">{{ truncatedPath }}</span>
+ <gl-truncate :text="path" position="middle" class="bold" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 25478ad6f4f..97b4ceda033 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -5,6 +5,7 @@ import {
GlButton,
GlDropdown,
GlDropdownItem,
+ GlFormCheckbox,
GlTooltipDirective,
} from '@gitlab/ui';
@@ -25,6 +26,7 @@ export default {
GlButton,
GlDropdown,
GlDropdownItem,
+ GlFormCheckbox,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -59,10 +61,25 @@ export default {
default: '',
validator: value => value === '' || /(_desc)|(_asc)/g.test(value),
},
+ showCheckbox: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ checkboxChecked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
searchInputPlaceholder: {
type: String,
required: true,
},
+ suggestionsListClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending;
@@ -291,12 +308,19 @@ export default {
<template>
<div class="vue-filtered-search-bar-container d-md-flex">
+ <gl-form-checkbox
+ v-if="showCheckbox"
+ class="gl-align-self-center"
+ :checked="checkboxChecked"
+ @input="$emit('checked-input', $event)"
+ />
<gl-filtered-search
ref="filteredSearchInput"
v-model="filterValue"
:placeholder="searchInputPlaceholder"
:available-tokens="tokens"
:history-items="filteredRecentSearches"
+ :suggestions-list-class="suggestionsListClass"
class="flex-grow-1"
@history-item-selected="handleHistoryItemSelected"
@clear-history="handleClearHistory"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
index 89952623d0d..c24df5e081d 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
@@ -65,7 +65,7 @@ export default {
.then(({ data }) => {
this.milestones = data;
})
- .catch(() => createFlash(__('There was a problem fetching milestones.')))
+ .catch(() => createFlash({ message: __('There was a problem fetching milestones.') }))
.finally(() => {
this.loading = false;
});
diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
index e895a7a52ab..dde7e3ebe13 100644
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
@@ -10,6 +10,8 @@ const AutoComplete = {
Labels: 'labels',
Members: 'members',
MergeRequests: 'mergeRequests',
+ Milestones: 'milestones',
+ Snippets: 'snippets',
};
const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings
@@ -120,6 +122,22 @@ const autoCompleteMap = {
return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`;
},
},
+ [AutoComplete.Milestones]: {
+ filterValues() {
+ return this[AutoComplete.Milestones];
+ },
+ menuItemTemplate({ original }) {
+ return escape(original.title);
+ },
+ },
+ [AutoComplete.Snippets]: {
+ filterValues() {
+ return this[AutoComplete.Snippets];
+ },
+ menuItemTemplate({ original }) {
+ return `<small>${original.id}</small> ${escape(original.title)}`;
+ },
+ },
};
export default {
@@ -157,8 +175,8 @@ export default {
menuItemTemplate: autoCompleteMap[AutoComplete.Labels].menuItemTemplate,
selectTemplate: ({ original }) =>
NON_WORD_OR_INTEGER.test(original.title)
- ? `~"${original.title}"`
- : `~${original.title}`,
+ ? `~"${escape(original.title)}"`
+ : `~${escape(original.title)}`,
values: this.getValues(AutoComplete.Labels),
},
{
@@ -168,6 +186,20 @@ export default {
selectTemplate: ({ original }) => original.reference || `!${original.iid}`,
values: this.getValues(AutoComplete.MergeRequests),
},
+ {
+ trigger: '%',
+ lookup: 'title',
+ menuItemTemplate: autoCompleteMap[AutoComplete.Milestones].menuItemTemplate,
+ selectTemplate: ({ original }) => `%"${escape(original.title)}"`,
+ values: this.getValues(AutoComplete.Milestones),
+ },
+ {
+ trigger: '$',
+ fillAttr: 'id',
+ lookup: value => value.id + value.title,
+ menuItemTemplate: autoCompleteMap[AutoComplete.Snippets].menuItemTemplate,
+ values: this.getValues(AutoComplete.Snippets),
+ },
],
});
diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue
deleted file mode 100644
index 4b91d4c00e3..00000000000
--- a/app/assets/javascripts/vue_shared/components/gl_modal.vue
+++ /dev/null
@@ -1,6 +0,0 @@
-<script>
-// This file was only introduced to not break master and shall be delete soon.
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
-
-export default DeprecatedModal2;
-</script>
diff --git a/app/assets/javascripts/vue_shared/components/integrations_help_text.vue b/app/assets/javascripts/vue_shared/components/integrations_help_text.vue
new file mode 100644
index 00000000000..4939b5aa98c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/integrations_help_text.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+
+export default {
+ name: 'IntegrationsHelpText',
+ components: {
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ message: {
+ type: String,
+ required: true,
+ },
+ messageUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-sprintf :message="message">
+ <template #link="{ content }">
+ <gl-link :href="messageUrl" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" :size="12" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+</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 80c03342f11..33e77b6510c 100644
--- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
+++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
@@ -22,11 +22,21 @@ export default {
required: false,
default: true,
},
+ clear: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
watch: {
value(newVal) {
this.saveValue(this.serialize(newVal));
},
+ clear(newVal) {
+ if (newVal) {
+ localStorage.removeItem(this.storageKey);
+ }
+ },
},
mounted() {
// On mount, trigger update if we actually have a localStorageValue
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 65116ed8ca3..9cfba85e0d8 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -141,10 +141,9 @@ export default {
addMultipleToDiscussionWarning() {
return sprintf(
__(
- '%{icon}You are about to add %{usersTag} people to the discussion. They will all receive a notification.',
+ 'You are about to add %{usersTag} people to the discussion. They will all receive a notification.',
),
{
- icon: '<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>',
usersTag: `<strong><span class="js-referenced-users-count">${this.referencedUsers.length}</span></strong>`,
},
false,
@@ -175,9 +174,10 @@ export default {
issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
epics: this.enableAutocomplete,
- milestones: this.enableAutocomplete,
+ milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- snippets: this.enableAutocomplete,
+ snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
+ vulnerabilities: this.enableAutocomplete,
},
true,
);
@@ -293,6 +293,7 @@ export default {
<template v-if="previewMarkdown && !markdownPreviewLoading">
<div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div>
<div v-if="shouldShowReferencedUsers" class="referenced-users">
+ <gl-icon name="warning-solid" />
<span v-html="addMultipleToDiscussionWarning"></span>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index fb9636ba734..fb51840b689 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
@@ -72,6 +72,9 @@ export default {
}
return __('Applying suggestions...');
},
+ isLoggedIn() {
+ return Boolean(gon.current_user_id);
+ },
},
methods: {
applySuggestion() {
@@ -141,6 +144,7 @@ export default {
</gl-button>
<span v-gl-tooltip.viewport="tooltipMessage" tabindex="0">
<gl-button
+ v-if="isLoggedIn"
class="btn-inverted js-apply-btn btn-grouped"
:disabled="isDisableButton"
variant="success"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 5d47aed9643..5824cb9438f 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -61,43 +61,59 @@ export default {
<span v-if="canAttachFile" class="uploading-container">
<span class="uploading-progress-container hide">
<template>
- <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" />
+ <gl-icon name="media" />
</template>
<span class="attaching-file-message"></span>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<span class="uploading-progress">0%</span>
- <gl-loading-icon inline class="align-text-bottom" />
+ <gl-loading-icon inline />
</span>
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
- <template>
- <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" />
- </template>
+ <gl-icon name="media" />
</span>
<span class="uploading-error-message"></span>
<gl-sprintf
:message="
__(
- '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}',
+ '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}.',
)
"
>
<template #retryButton="{content}">
- <button class="retry-uploading-link" type="button">{{ content }}</button>
+ <gl-button
+ variant="link"
+ category="primary"
+ class="retry-uploading-link gl-vertical-align-baseline"
+ >
+ {{ content }}
+ </gl-button>
</template>
<template #newFileButton="{content}">
- <button class="attach-new-file markdown-selector" type="button">{{ content }}</button>
+ <gl-button
+ variant="link"
+ category="primary"
+ class="markdown-selector attach-new-file gl-vertical-align-baseline"
+ >
+ {{ content }}
+ </gl-button>
</template>
</gl-sprintf>
</span>
- <gl-button class="markdown-selector button-attach-file" variant="link">
- <template>
- <gl-icon name="media" :size="16" />
- </template>
- <span class="text-attach-file">{{ __('Attach a file') }}</span>
+ <gl-button
+ icon="media"
+ variant="link"
+ category="primary"
+ class="markdown-selector button-attach-file gl-vertical-align-text-bottom"
+ >
+ {{ __('Attach a file') }}
</gl-button>
- <gl-button class="btn btn-default btn-sm hide button-cancel-uploading-files" variant="link">
+ <gl-button
+ variant="link"
+ category="primary"
+ class="button-cancel-uploading-files gl-vertical-align-baseline hide"
+ >
{{ __('Cancel') }}
</gl-button>
</span>
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
index 8fa3d439fc1..484dbb8fef5 100644
--- 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
@@ -6,7 +6,13 @@ import { s__, sprintf } from '~/locale';
export default {
name: 'UserActionButtons',
- components: { ActionButtonGroup, RemoveMemberButton, LeaveButton },
+ components: {
+ ActionButtonGroup,
+ RemoveMemberButton,
+ LeaveButton,
+ LdapOverrideButton: () =>
+ import('ee_component/vue_shared/components/members/ldap/ldap_override_button.vue'),
+ },
props: {
member: {
type: Object,
@@ -57,5 +63,8 @@ export default {
:title="s__('Member|Remove member')"
/>
</div>
+ <div v-else-if="permissions.canOverride && !member.isOverridden" class="gl-px-1">
+ <ldap-override-button :member="member" />
+ </div>
</action-button-group>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/constants.js b/app/assets/javascripts/vue_shared/components/members/constants.js
index 6509779053e..5885420a122 100644
--- a/app/assets/javascripts/vue_shared/components/members/constants.js
+++ b/app/assets/javascripts/vue_shared/components/members/constants.js
@@ -51,6 +51,7 @@ export const FIELDS = [
key: 'actions',
thClass: 'col-actions',
tdClass: 'col-actions',
+ showFunction: 'showActionsField',
},
];
diff --git a/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue b/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue
new file mode 100644
index 00000000000..0a8af81c1d1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue
@@ -0,0 +1,99 @@
+<script>
+import { GlDatepicker } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import { getDateInFuture } from '~/lib/utils/datetime_utility';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'ExpirationDatepicker',
+ components: { GlDatepicker },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedDate: null,
+ busy: false,
+ };
+ },
+ computed: {
+ minDate() {
+ // Members expire at the beginning of the day.
+ // The first selectable day should be tomorrow.
+ const today = new Date();
+ const beginningOfToday = new Date(today.setHours(0, 0, 0, 0));
+
+ return getDateInFuture(beginningOfToday, 1);
+ },
+ disabled() {
+ return (
+ this.busy ||
+ !this.permissions.canUpdate ||
+ (this.permissions.canOverride && !this.member.isOverridden)
+ );
+ },
+ },
+ mounted() {
+ if (this.member.expiresAt) {
+ this.selectedDate = new Date(this.member.expiresAt);
+ }
+ },
+ methods: {
+ ...mapActions(['updateMemberExpiration']),
+ handleInput(date) {
+ this.busy = true;
+ this.updateMemberExpiration({
+ memberId: this.member.id,
+ expiresAt: date,
+ })
+ .then(() => {
+ this.$toast.show(s__('Members|Expiration date updated successfully.'));
+ this.busy = false;
+ })
+ .catch(() => {
+ this.busy = false;
+ });
+ },
+ handleClear() {
+ this.busy = true;
+
+ this.updateMemberExpiration({
+ memberId: this.member.id,
+ expiresAt: null,
+ })
+ .then(() => {
+ this.$toast.show(s__('Members|Expiration date removed successfully.'));
+ this.selectedDate = null;
+ this.busy = false;
+ })
+ .catch(() => {
+ this.busy = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <!-- `:target="null"` allows the datepicker to be opened on focus -->
+ <!-- `:container="null"` renders the datepicker in the body to prevent conflicting CSS table styles -->
+ <gl-datepicker
+ v-model="selectedDate"
+ class="gl-max-w-full"
+ show-clear-button
+ :target="null"
+ :container="null"
+ :min-date="minDate"
+ :placeholder="__('Expiration date')"
+ :disabled="disabled"
+ @input="handleInput"
+ @clear="handleClear"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
index c1a80a85dbe..a4f67caff31 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
+++ b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
@@ -1,6 +1,13 @@
<script>
import { mapState } from 'vuex';
import { GlTable, GlBadge } from '@gitlab/ui';
+import MembersTableCell from 'ee_else_ce/vue_shared/components/members/table/members_table_cell.vue';
+import {
+ canOverride,
+ canRemove,
+ canResend,
+ canUpdate,
+} from 'ee_else_ce/vue_shared/components/members/utils';
import { FIELDS } from '../constants';
import initUserPopovers from '~/user_popovers';
import MemberAvatar from './member_avatar.vue';
@@ -8,9 +15,9 @@ 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';
+import ExpirationDatepicker from './expiration_datepicker.vue';
export default {
name: 'MembersTable',
@@ -25,23 +32,56 @@ export default {
MemberActionButtons,
RoleDropdown,
RemoveGroupLinkModal,
+ ExpirationDatepicker,
+ LdapOverrideConfirmationModal: () =>
+ import(
+ 'ee_component/vue_shared/components/members/ldap/ldap_override_confirmation_modal.vue'
+ ),
},
computed: {
- ...mapState(['members', 'tableFields']),
+ ...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId', 'sourceId']),
filteredFields() {
- return FIELDS.filter(field => this.tableFields.includes(field.key));
+ return FIELDS.filter(field => this.tableFields.includes(field.key) && this.showField(field));
+ },
+ userIsLoggedIn() {
+ return this.currentUserId !== null;
},
},
mounted() {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
},
+ methods: {
+ showField(field) {
+ if (!Object.prototype.hasOwnProperty.call(field, 'showFunction')) {
+ return true;
+ }
+
+ return this[field.showFunction]();
+ },
+ showActionsField() {
+ if (!this.userIsLoggedIn) {
+ return false;
+ }
+
+ return this.members.some(member => {
+ return (
+ canRemove(member, this.sourceId) ||
+ canResend(member) ||
+ canUpdate(member, this.currentUserId, this.sourceId) ||
+ canOverride(member)
+ );
+ });
+ },
+ },
};
</script>
<template>
<div>
<gl-table
+ v-bind="tableAttrs.table"
class="members-table"
+ data-testid="members-table"
head-variant="white"
stacked="lg"
:fields="filteredFields"
@@ -50,6 +90,7 @@ export default {
thead-class="border-bottom"
:empty-text="__('No members found')"
show-empty
+ :tbody-tr-attr="tableAttrs.tr"
>
<template #cell(account)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser }" :member="member">
@@ -85,11 +126,17 @@ export default {
<template #cell(maxRole)="{ item: member }">
<members-table-cell #default="{ permissions }" :member="member">
- <role-dropdown v-if="permissions.canUpdate" :member="member" />
+ <role-dropdown v-if="permissions.canUpdate" :permissions="permissions" :member="member" />
<gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge>
</members-table-cell>
</template>
+ <template #cell(expiration)="{ item: member }">
+ <members-table-cell #default="{ permissions }" :member="member">
+ <expiration-datepicker :permissions="permissions" :member="member" />
+ </members-table-cell>
+ </template>
+
<template #cell(actions)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member">
<member-action-buttons
@@ -106,5 +153,6 @@ export default {
</template>
</gl-table>
<remove-group-link-modal />
+ <ldap-override-confirmation-modal />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue
index 5602978bb6c..11e1aef9803 100644
--- 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
@@ -1,6 +1,7 @@
<script>
import { mapState } from 'vuex';
import { MEMBER_TYPES } from '../constants';
+import { isGroup, isDirectMember, isCurrentUser, canRemove, canResend, canUpdate } from '../utils';
export default {
name: 'MembersTableCell',
@@ -13,7 +14,7 @@ export default {
computed: {
...mapState(['sourceId', 'currentUserId']),
isGroup() {
- return Boolean(this.member.sharedWithGroup);
+ return isGroup(this.member);
},
isInvite() {
return Boolean(this.member.invite);
@@ -33,19 +34,19 @@ export default {
return MEMBER_TYPES.user;
},
isDirectMember() {
- return this.isGroup || this.member.source?.id === this.sourceId;
+ return isDirectMember(this.member, this.sourceId);
},
isCurrentUser() {
- return this.member.user?.id === this.currentUserId;
+ return isCurrentUser(this.member, this.currentUserId);
},
canRemove() {
- return this.isDirectMember && this.member.canRemove;
+ return canRemove(this.member, this.sourceId);
},
canResend() {
- return Boolean(this.member.invite?.canResend);
+ return canResend(this.member);
},
canUpdate() {
- return !this.isCurrentUser && this.isDirectMember && this.member.canUpdate;
+ return canUpdate(this.member, this.currentUserId, this.sourceId);
},
},
render() {
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
index 2b40ccc3a9d..6f6cae6072d 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue
@@ -9,12 +9,18 @@ export default {
components: {
GlDropdown,
GlDropdownItem,
+ LdapDropdownItem: () =>
+ import('ee_component/vue_shared/components/members/ldap/ldap_dropdown_item.vue'),
},
props: {
member: {
type: Object,
required: true,
},
+ permissions: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -22,8 +28,21 @@ export default {
busy: false,
};
},
+ computed: {
+ disabled() {
+ return this.busy || (this.permissions.canOverride && !this.member.isOverridden);
+ },
+ },
mounted() {
this.isDesktop = bp.isDesktop();
+
+ // Bootstrap Vue and GlDropdown to not support adding attributes to the dropdown toggle
+ // This can be changed once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1060 is implemented
+ const dropdownToggle = this.$refs.glDropdown.$el.querySelector('.dropdown-toggle');
+
+ if (dropdownToggle) {
+ dropdownToggle.setAttribute('data-qa-selector', 'access_level_dropdown');
+ }
},
methods: {
...mapActions(['updateMemberRole']),
@@ -52,19 +71,25 @@ export default {
<template>
<gl-dropdown
+ ref="glDropdown"
:right="!isDesktop"
:text="member.accessLevel.stringValue"
:header-text="__('Change permissions')"
- :disabled="busy"
+ :disabled="disabled"
>
<gl-dropdown-item
v-for="(value, name) in member.validRoles"
:key="value"
is-check-item
:is-checked="value === member.accessLevel.integerValue"
+ data-qa-selector="access_level_link"
@click="handleSelect(value, name)"
>
{{ name }}
</gl-dropdown-item>
+ <ldap-dropdown-item
+ v-if="permissions.canOverride && member.isOverridden"
+ :member-id="member.id"
+ />
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/utils.js b/app/assets/javascripts/vue_shared/components/members/utils.js
index 782a0b7f96b..4229a62c0a7 100644
--- a/app/assets/javascripts/vue_shared/components/members/utils.js
+++ b/app/assets/javascripts/vue_shared/components/members/utils.js
@@ -17,3 +17,32 @@ export const generateBadges = (member, isCurrentUser) => [
variant: 'info',
},
];
+
+export const isGroup = member => {
+ return Boolean(member.sharedWithGroup);
+};
+
+export const isDirectMember = (member, sourceId) => {
+ return isGroup(member) || member.source?.id === sourceId;
+};
+
+export const isCurrentUser = (member, currentUserId) => {
+ return member.user?.id === currentUserId;
+};
+
+export const canRemove = (member, sourceId) => {
+ return isDirectMember(member, sourceId) && member.canRemove;
+};
+
+export const canResend = member => {
+ return Boolean(member.invite?.canResend);
+};
+
+export const canUpdate = (member, currentUserId, sourceId) => {
+ return (
+ !isCurrentUser(member, currentUserId) && isDirectMember(member, sourceId) && member.canUpdate
+ );
+};
+
+// Defined in `ee/app/assets/javascripts/vue_shared/components/members/utils.js`
+export const canOverride = () => false;
diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
index cad4439ecea..de9c84dd157 100644
--- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
+++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
@@ -1,8 +1,7 @@
<script>
-import $ from 'jquery';
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import Clipboard from 'clipboard';
-import { __ } from '~/locale';
+import { uniqueId } from 'lodash';
export default {
components: {
@@ -17,6 +16,11 @@ export default {
required: false,
default: '',
},
+ id: {
+ type: String,
+ required: false,
+ default: () => uniqueId('modal-copy-button-'),
+ },
container: {
type: String,
required: false,
@@ -52,7 +56,6 @@ export default {
default: null,
},
},
- copySuccessText: __('Copied'),
computed: {
modalDomId() {
return this.modalId ? `#${this.modalId}` : '';
@@ -68,11 +71,11 @@ export default {
});
this.clipboard
.on('success', e => {
- this.updateTooltip(e.trigger);
+ this.$root.$emit('bv::hide::tooltip', this.id);
this.$emit('success', e);
// Clear the selection and blur the trigger so it loses its border
e.clearSelection();
- $(e.trigger).blur();
+ e.trigger.blur();
})
.on('error', e => this.$emit('error', e));
});
@@ -82,29 +85,11 @@ export default {
this.clipboard.destroy();
}
},
- methods: {
- updateTooltip(target) {
- const $target = $(target);
- const originalTitle = $target.data('originalTitle');
-
- if ($target.tooltip) {
- /**
- * The original tooltip will continue staying there unless we remove it by hand.
- * $target.tooltip('hide') isn't working.
- */
- $('.tooltip').remove();
- $target.attr('title', this.$options.copySuccessText);
- $target.tooltip('_fixTitle');
- $target.tooltip('show');
- $target.attr('title', originalTitle);
- $target.tooltip('_fixTitle');
- }
- },
- },
};
</script>
<template>
<gl-button
+ :id="id"
v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
:class="cssClasses"
:data-clipboard-target="target"
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
index 8e85d93e6d1..1fc39c7cb8e 100644
--- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
@@ -308,6 +308,6 @@ export default {
@input="handlePageChange"
/>
- <slot v-if="!showItems" name="emtpy-state"></slot>
+ <slot v-if="!showItems" name="empty-state"></slot>
</div>
</template>
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 06b4309ad42..4d47a34c9a3 100644
--- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
@@ -30,8 +30,13 @@ export default {
metadataSlots: [],
};
},
- mounted() {
- this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith('metadata-'));
+ async mounted() {
+ const METADATA_PREFIX = 'metadata-';
+ this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith(METADATA_PREFIX));
+
+ // we need to wait for next tick to ensure that dynamic names slots are picked up
+ await this.$nextTick();
+ this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith(METADATA_PREFIX));
},
};
</script>
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 e1652f54982..82060d2e4ad 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
@@ -1,8 +1,7 @@
<script>
import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui';
-import { isSafeURL } from '~/lib/utils/url_utility';
+import { isSafeURL, joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IMAGE_TABS } from '../../constants';
import UploadImageTab from './upload_image_tab.vue';
@@ -15,7 +14,6 @@ export default {
GlTabs,
GlTab,
},
- mixins: [glFeatureFlagMixin()],
props: {
imageRoot: {
type: String,
@@ -34,10 +32,10 @@ export default {
},
modalTitle: __('Image details'),
okTitle: __('Insert image'),
- urlTabTitle: __('By URL'),
+ urlTabTitle: __('Link to an image'),
urlLabel: __('Image URL'),
descriptionLabel: __('Description'),
- uploadTabTitle: __('Upload file'),
+ uploadTabTitle: __('Upload an image'),
computed: {
altText() {
return this.description;
@@ -54,7 +52,7 @@ export default {
this.$refs.modal.show();
},
onOk(event) {
- if (this.glFeatures.sseImageUploads && this.tabIndex === IMAGE_TABS.UPLOAD_TAB) {
+ if (this.tabIndex === IMAGE_TABS.UPLOAD_TAB) {
this.submitFile(event);
return;
}
@@ -74,7 +72,7 @@ export default {
return;
}
- const imageUrl = `${this.imageRoot}${file.name}`;
+ const imageUrl = joinPaths(this.imageRoot, file.name);
this.$emit('addImage', { imageUrl, file, altText: altText || file.name });
},
@@ -108,7 +106,7 @@ export default {
:ok-title="$options.okTitle"
@ok="onOk"
>
- <gl-tabs v-if="glFeatures.sseImageUploads" v-model="tabIndex">
+ <gl-tabs v-model="tabIndex">
<!-- Upload file Tab -->
<gl-tab :title="$options.uploadTabTitle">
<upload-image-tab ref="uploadImageTab" @input="setFile" />
@@ -128,17 +126,6 @@ export default {
</gl-tab>
</gl-tabs>
- <gl-form-group
- v-else
- class="gl-mt-5 gl-mb-3"
- :label="$options.urlLabel"
- label-for="url-input"
- :state="!Boolean(urlError)"
- :invalid-feedback="urlError"
- >
- <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
- </gl-form-group>
-
<!-- Description Input -->
<gl-form-group :label="$options.descriptionLabel" label-for="description-input">
<gl-form-input id="description-input" ref="descriptionInput" v-model="description" />
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 c2518441506..9eacf74bba8 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
@@ -53,7 +53,6 @@ export default {
imageRoot: {
type: String,
required: true,
- validator: prop => prop.endsWith('/'),
},
},
data() {
@@ -115,10 +114,9 @@ export default {
if (file) {
this.$emit('uploadImage', { file, imageUrl });
- // TODO - ensure that the actual repo URL for the image is used in Markdown mode
}
- addImage(this.editorInstance, image);
+ addImage(this.editorInstance, image, file);
},
onOpenInsertVideoModal() {
this.$refs.insertVideoModal.show();
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
index 2bce691e793..9744e25a8e1 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
@@ -99,6 +99,10 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) =>
? `\n\n${node.innerText}\n\n`
: baseRenderer.convert(node, subContent);
},
+ IMG(node) {
+ const { originalSrc } = node.dataset;
+ return `![${node.alt}](${originalSrc || node.src})`;
+ },
};
};
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 8b3fbcabcfa..463e64b4936 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
@@ -34,6 +34,20 @@ const buildVideoIframe = src => {
return wrapper;
};
+const buildImg = (alt, originalSrc, file) => {
+ const img = document.createElement('img');
+ const src = file ? URL.createObjectURL(file) : originalSrc;
+ const attributes = { alt, src };
+
+ if (file) {
+ img.dataset.originalSrc = originalSrc;
+ }
+
+ Object.assign(img, attributes);
+
+ return img;
+};
+
export const generateToolbarItem = config => {
const { icon, classes, event, command, tooltip, isDivider } = config;
@@ -59,7 +73,14 @@ export const addCustomEventListener = (editorApi, event, handler) => {
export const removeCustomEventListener = (editorApi, event, handler) =>
editorApi.eventManager.removeEventHandler(event, handler);
-export const addImage = ({ editor }, image) => editor.exec('AddImage', image);
+export const addImage = ({ editor }, { altText, imageUrl }, file) => {
+ if (editor.isWysiwygMode()) {
+ const img = buildImg(altText, imageUrl, file);
+ editor.getSquire().insertElement(img);
+ } else {
+ editor.insertText(`![${altText}](${imageUrl})`);
+ }
+};
export const insertVideo = ({ editor }, url) => {
const videoIframe = buildVideoIframe(url);
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql
new file mode 100644
index 00000000000..ff0626167a9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql
@@ -0,0 +1,20 @@
+query getRunnerPlatforms($projectPath: ID!, $groupPath: ID!) {
+ runnerPlatforms {
+ nodes {
+ name
+ humanReadableName
+ architectures {
+ nodes {
+ name
+ downloadLocation
+ }
+ }
+ }
+ }
+ project(fullPath: $projectPath) {
+ id
+ }
+ group(fullPath: $groupPath) {
+ id
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql
new file mode 100644
index 00000000000..643c1991807
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql
@@ -0,0 +1,16 @@
+query runnerSetupInstructions(
+ $platform: String!
+ $architecture: String!
+ $projectId: ID!
+ $groupId: ID!
+) {
+ runnerSetup(
+ platform: $platform
+ architecture: $architecture
+ projectId: $projectId
+ groupId: $groupId
+ ) {
+ installInstructions
+ registerInstructions
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
new file mode 100644
index 00000000000..b70b1277155
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
@@ -0,0 +1,220 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlModal,
+ GlModalDirective,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+} from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import getRunnerPlatforms from './graphql/queries/get_runner_platforms.query.graphql';
+import getRunnerSetupInstructions from './graphql/queries/get_runner_setup.query.graphql';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlIcon,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ groupPath: {
+ default: '',
+ },
+ },
+ apollo: {
+ runnerPlatforms: {
+ query: getRunnerPlatforms,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ groupPath: this.groupPath,
+ };
+ },
+ update(data) {
+ return data;
+ },
+ error() {
+ this.showAlert = true;
+ },
+ },
+ },
+ data() {
+ return {
+ showAlert: false,
+ selectedPlatformArchitectures: [],
+ selectedPlatform: {},
+ selectedArchitecture: {},
+ runnerPlatforms: {},
+ instructions: {},
+ };
+ },
+ computed: {
+ isPlatformSelected() {
+ return Object.keys(this.selectedPlatform).length > 0;
+ },
+ instructionsEmpty() {
+ return this.instructions && Object.keys(this.instructions).length === 0;
+ },
+ groupId() {
+ return this.runnerPlatforms?.group?.id ?? '';
+ },
+ projectId() {
+ return this.runnerPlatforms?.project?.id ?? '';
+ },
+ platforms() {
+ return this.runnerPlatforms.runnerPlatforms?.nodes;
+ },
+ },
+ methods: {
+ selectPlatform(name) {
+ this.selectedPlatform = this.platforms.find(platform => platform.name === name);
+ this.selectedPlatformArchitectures = this.selectedPlatform?.architectures?.nodes;
+ [this.selectedArchitecture] = this.selectedPlatformArchitectures;
+ this.selectArchitecture(this.selectedArchitecture);
+ },
+ selectArchitecture(architecture) {
+ this.selectedArchitecture = architecture;
+
+ this.$apollo.addSmartQuery('instructions', {
+ variables() {
+ return {
+ platform: this.selectedPlatform.name,
+ architecture: this.selectedArchitecture.name,
+ projectId: this.projectId,
+ groupId: this.groupId,
+ };
+ },
+ query: getRunnerSetupInstructions,
+ update(data) {
+ return data?.runnerSetup;
+ },
+ error() {
+ this.showAlert = true;
+ },
+ });
+ },
+ toggleAlert(state) {
+ this.showAlert = state;
+ },
+ },
+ modalId: 'installation-instructions-modal',
+ i18n: {
+ installARunner: __('Install a Runner'),
+ architecture: s__('Runners|Architecture'),
+ downloadInstallBinary: s__('Runners|Download and Install Binary'),
+ downloadLatestBinary: s__('Runners|Download Latest Binary'),
+ registerRunner: s__('Runners|Register Runner'),
+ method: __('Method'),
+ fetchError: s__('An error has occurred fetching instructions'),
+ instructions: __('Show Runner installation instructions'),
+ },
+ closeButton: {
+ text: __('Close'),
+ attributes: [{ variant: 'default' }],
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-button v-gl-modal-directive="$options.modalId" data-testid="show-modal-button">
+ {{ $options.i18n.instructions }}
+ </gl-button>
+ <gl-modal
+ :modal-id="$options.modalId"
+ :title="$options.i18n.installARunner"
+ :action-secondary="$options.closeButton"
+ >
+ <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
+ {{ $options.i18n.fetchError }}
+ </gl-alert>
+ <h5>{{ __('Environment') }}</h5>
+ <gl-button-group class="gl-mb-5">
+ <gl-button
+ v-for="platform in platforms"
+ :key="platform.name"
+ data-testid="platform-button"
+ @click="selectPlatform(platform.name)"
+ >
+ {{ platform.humanReadableName }}
+ </gl-button>
+ </gl-button-group>
+ <template v-if="isPlatformSelected">
+ <h5>
+ {{ $options.i18n.architecture }}
+ </h5>
+ <gl-dropdown class="gl-mb-5" :text="selectedArchitecture.name">
+ <gl-dropdown-item
+ v-for="architecture in selectedPlatformArchitectures"
+ :key="architecture.name"
+ data-testid="architecture-dropdown-item"
+ @click="selectArchitecture(architecture)"
+ >
+ {{ architecture.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <div class="gl-display-flex gl-align-items-center gl-mb-5">
+ <h5>{{ $options.i18n.downloadInstallBinary }}</h5>
+ <gl-button
+ class="gl-ml-auto"
+ :href="selectedArchitecture.downloadLocation"
+ download
+ data-testid="binary-download-button"
+ >
+ {{ $options.i18n.downloadLatestBinary }}
+ </gl-button>
+ </div>
+ </template>
+ <template v-if="!instructionsEmpty">
+ <div class="gl-display-flex">
+ <pre
+ class="bg-light gl-flex-fill-1 gl-white-space-pre-line"
+ data-testid="binary-instructions"
+ >
+ {{ instructions.installInstructions }}
+ </pre>
+ <gl-button
+ class="gl-align-self-start gl-ml-2 gl-mt-2"
+ category="tertiary"
+ variant="link"
+ :data-clipboard-text="instructions.installationInstructions"
+ >
+ <gl-icon name="copy-to-clipboard" />
+ </gl-button>
+ </div>
+
+ <hr />
+ <h5 class="gl-mb-5">{{ $options.i18n.registerRunner }}</h5>
+ <h5 class="gl-mb-5">{{ $options.i18n.method }}</h5>
+ <div class="gl-display-flex">
+ <pre
+ class="bg-light gl-flex-fill-1 gl-white-space-pre-line"
+ data-testid="runner-instructions"
+ >
+ {{ instructions.registerInstructions }}
+ </pre>
+ <gl-button
+ class="gl-align-self-start gl-ml-2 gl-mt-2"
+ category="tertiary"
+ variant="link"
+ :data-clipboard-text="instructions.registerInstructions"
+ >
+ <gl-icon name="copy-to-clipboard" />
+ </gl-button>
+ </div>
+ </template>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
new file mode 100644
index 00000000000..1d3bd312b09
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
@@ -0,0 +1,211 @@
+<script>
+import {
+ GlIcon,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownForm,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlButton,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
+
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ components: {
+ GlIcon,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownForm,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlButton,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ projectsFetchPath: {
+ type: String,
+ required: true,
+ },
+ dropdownButtonTitle: {
+ type: String,
+ required: true,
+ },
+ dropdownHeaderTitle: {
+ type: String,
+ required: true,
+ },
+ moveInProgress: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ projectsListLoading: false,
+ projectsListLoadFailed: false,
+ searchKey: '',
+ projects: [],
+ selectedProject: null,
+ projectItemClick: false,
+ };
+ },
+ computed: {
+ hasNoSearchResults() {
+ return Boolean(
+ !this.projectsListLoading &&
+ !this.projectsListLoadFailed &&
+ this.searchKey &&
+ !this.projects.length,
+ );
+ },
+ failedToLoadResults() {
+ return !this.projectsListLoading && this.projectsListLoadFailed;
+ },
+ },
+ watch: {
+ searchKey(value = '') {
+ this.fetchProjects(value);
+ },
+ },
+ methods: {
+ fetchProjects(search = '') {
+ this.projectsListLoading = true;
+ this.projectsListLoadFailed = false;
+ return axios
+ .get(this.projectsFetchPath, {
+ params: {
+ search,
+ },
+ })
+ .then(({ data }) => {
+ this.projects = data;
+ this.$refs.searchInput.focusInput();
+ })
+ .catch(() => {
+ this.projectsListLoadFailed = true;
+ })
+ .finally(() => {
+ this.projectsListLoading = false;
+ });
+ },
+ isSelectedProject(project) {
+ if (this.selectedProject) {
+ return this.selectedProject.id === project.id;
+ }
+ return false;
+ },
+ /**
+ * This handler is to prevent dropdown
+ * from closing when an item is selected
+ * and emit an event only when dropdown closes.
+ */
+ handleDropdownHide(e) {
+ if (this.projectItemClick) {
+ e.preventDefault();
+ this.projectItemClick = false;
+ } else {
+ this.$emit('dropdown-close');
+ }
+ },
+ handleDropdownCloseClick() {
+ this.$refs.dropdown.hide();
+ },
+ handleProjectSelect(project) {
+ this.selectedProject = project.id === this.selectedProject?.id ? null : project;
+ this.projectItemClick = true;
+ },
+ handleMoveClick() {
+ this.$refs.dropdown.hide();
+ this.$emit('move-issuable', this.selectedProject);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="block js-issuable-move-block issuable-move-dropdown sidebar-move-issue-dropdown">
+ <div
+ v-gl-tooltip.left.viewport
+ data-testid="move-collapsed"
+ :title="dropdownButtonTitle"
+ class="sidebar-collapsed-icon"
+ @click="$emit('toggle-collapse')"
+ >
+ <gl-icon name="arrow-right" />
+ </div>
+ <gl-dropdown
+ ref="dropdown"
+ :block="true"
+ :disabled="moveInProgress"
+ class="hide-collapsed"
+ toggle-class="js-sidebar-dropdown-toggle"
+ @shown="fetchProjects"
+ @hide="handleDropdownHide"
+ >
+ <template #button-content
+ ><gl-loading-icon v-if="moveInProgress" class="gl-mr-3" />{{
+ dropdownButtonTitle
+ }}</template
+ >
+ <gl-dropdown-form class="gl-pt-0">
+ <div
+ data-testid="header"
+ class="gl-display-flex gl-pb-3 gl-border-1 gl-border-b-solid gl-border-gray-100"
+ >
+ <span class="gl-flex-grow-1 gl-text-center gl-font-weight-bold gl-py-1">{{
+ dropdownHeaderTitle
+ }}</span>
+ <gl-button
+ variant="link"
+ icon="close"
+ class="gl-mr-2 gl-w-auto! gl-p-2!"
+ @click.prevent="handleDropdownCloseClick"
+ />
+ </div>
+ <gl-search-box-by-type
+ ref="searchInput"
+ v-model.trim="searchKey"
+ :placeholder="__('Search project')"
+ :debounce="300"
+ />
+ <div data-testid="content" class="dropdown-content">
+ <gl-loading-icon v-if="projectsListLoading" size="md" class="gl-p-5" />
+ <ul v-else>
+ <gl-dropdown-item
+ v-for="project in projects"
+ :key="project.id"
+ :is-check-item="true"
+ :is-checked="isSelectedProject(project)"
+ @click.stop.prevent="handleProjectSelect(project)"
+ >{{ project.name_with_namespace }}</gl-dropdown-item
+ >
+ </ul>
+ <div v-if="hasNoSearchResults" class="gl-text-center gl-p-3">
+ {{ __('No matching results') }}
+ </div>
+ <div v-if="failedToLoadResults" class="gl-text-center gl-p-3">
+ {{ __('Failed to load projects') }}
+ </div>
+ </div>
+ <div
+ data-testid="footer"
+ class="gl-pt-3 gl-px-3 gl-border-1 gl-border-t-solid gl-border-gray-100"
+ >
+ <gl-button
+ category="primary"
+ variant="success"
+ :disabled="!Boolean(selectedProject)"
+ class="gl-text-center! issuable-move-button"
+ @click="handleMoveClick"
+ >{{ __('Move') }}</gl-button
+ >
+ </div>
+ </gl-dropdown-form>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
index c2ebf78d541..973cc314ee3 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
@@ -1,11 +1,10 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@@ -45,12 +44,9 @@ export default {
<template>
<div
- v-tooltip
+ v-gl-tooltip.left.viewport
:title="labelsList"
class="sidebar-collapsed-icon"
- data-placement="left"
- data-container="body"
- data-boundary="viewport"
@click="handleClick"
>
<gl-icon name="labels" />
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 353dee862d0..a365673f7a1 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
@@ -92,6 +92,13 @@ export default {
}
}
},
+ handleComponentAppear() {
+ // We can avoid putting `catch` block here
+ // as failure is handled within actions.js already.
+ return this.fetchLabels().then(() => {
+ this.$refs.searchInput.focusInput();
+ });
+ },
/**
* We want to remove loaded labels to ensure component
* fetches fresh set of labels every time when shown.
@@ -139,7 +146,7 @@ export default {
</script>
<template>
- <gl-intersection-observer @appear="fetchLabels" @disappear="handleComponentDisappear">
+ <gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear">
<div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
<div
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
@@ -158,8 +165,8 @@ export default {
</div>
<div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type
+ ref="searchInput"
v-model="searchKey"
- :autofocus="true"
:disabled="labelsFetchInProgress"
data-qa-selector="dropdown_input_field"
/>
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 e624bd1eaee..14b46c1c431 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
@@ -20,7 +20,7 @@ export const receiveLabelsFailure = ({ commit }) => {
};
export const fetchLabels = ({ state, dispatch }) => {
dispatch('requestLabels');
- axios
+ return axios
.get(state.labelsFetchPath)
.then(({ data }) => {
dispatch('receiveLabelsSuccess', data);
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
new file mode 100644
index 00000000000..c5bbe1b33fb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
@@ -0,0 +1,29 @@
+<script>
+import { GlDropdown, GlDropdownForm } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdownForm,
+ GlDropdown,
+ },
+ props: {
+ headerText: {
+ type: String,
+ required: true,
+ },
+ text: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown class="show" :text="text" :header-text="headerText">
+ <slot name="search"></slot>
+ <gl-dropdown-form>
+ <slot name="items"></slot>
+ </gl-dropdown-form>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql
new file mode 100644
index 00000000000..612a0c02e82
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql
@@ -0,0 +1,13 @@
+query issueParticipants($id: IssueID!) {
+ issue(id: $id) {
+ participants {
+ nodes {
+ username
+ name
+ webUrl
+ avatarUrl
+ id
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql
new file mode 100644
index 00000000000..9ead95a3801
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql
@@ -0,0 +1,17 @@
+mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $projectPath: ID!) {
+ issueSetAssignees(
+ input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath }
+ ) {
+ issue {
+ assignees {
+ nodes {
+ username
+ id
+ name
+ webUrl
+ avatarUrl
+ }
+ }
+ }
+ }
+}
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 f2e9c4a4fbb..9b6d0a87374 100644
--- a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
+++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
@@ -1,7 +1,7 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
-import { roundOffFloat } from '~/lib/utils/common_utils';
+import { roundDownFloat } from '~/lib/utils/common_utils';
export default {
directives: {
@@ -89,7 +89,7 @@ export default {
return 0;
}
- const percent = roundOffFloat((count / this.totalCount) * 100, 1);
+ const percent = roundDownFloat((count / this.totalCount) * 100, 1);
if (percent > 0 && percent < 1) {
return '< 1';
}
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/constants.js b/app/assets/javascripts/vue_shared/components/upload_dropzone/constants.js
new file mode 100644
index 00000000000..85414704cb6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/constants.js
@@ -0,0 +1,9 @@
+// We may wish to make this more restrictive, as per
+// https://gitlab.com/gitlab-org/gitlab/issues/118611
+export const VALID_IMAGE_FILE_MIMETYPE = {
+ mimetype: 'image/*',
+ regex: /image\/.+/,
+};
+
+// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
+export const VALID_DATA_TRANSFER_TYPE = 'Files';
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
new file mode 100644
index 00000000000..b645758d891
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
@@ -0,0 +1,172 @@
+<script>
+import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { isValidImage } from './utils';
+import { VALID_DATA_TRANSFER_TYPE, VALID_IMAGE_FILE_MIMETYPE } from './constants';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ displayAsCard: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ enableDragBehavior: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ dropToStartMessage: {
+ type: String,
+ required: false,
+ default: __('Drop your files to start your upload.'),
+ },
+ isFileValid: {
+ type: Function,
+ required: false,
+ default: isValidImage,
+ },
+ validFileMimetypes: {
+ type: Array,
+ required: false,
+ default: () => [VALID_IMAGE_FILE_MIMETYPE.mimetype],
+ },
+ },
+ data() {
+ return {
+ dragCounter: 0,
+ isDragDataValid: false,
+ };
+ },
+ computed: {
+ dragging() {
+ return this.dragCounter !== 0;
+ },
+ iconStyles() {
+ return {
+ size: this.displayAsCard ? 24 : 16,
+ class: this.displayAsCard ? 'gl-mb-2' : 'gl-mr-3 gl-text-gray-500',
+ };
+ },
+ validMimeTypeString() {
+ return this.validFileMimetypes.join();
+ },
+ },
+ methods: {
+ isValidUpload(files) {
+ return files.every(this.isFileValid);
+ },
+ isValidDragDataType({ dataTransfer }) {
+ return Boolean(dataTransfer && dataTransfer.types.some(t => t === VALID_DATA_TRANSFER_TYPE));
+ },
+ ondrop({ dataTransfer = {} }) {
+ this.dragCounter = 0;
+ // User already had feedback when dropzone was active, so bail here
+ if (!this.isDragDataValid) {
+ return;
+ }
+
+ const { files } = dataTransfer;
+ if (!this.isValidUpload(Array.from(files))) {
+ this.$emit('error');
+ return;
+ }
+
+ this.$emit('change', files);
+ },
+ ondragenter(e) {
+ this.dragCounter += 1;
+ this.isDragDataValid = this.isValidDragDataType(e);
+ },
+ ondragleave() {
+ this.dragCounter -= 1;
+ },
+ openFileUpload() {
+ this.$refs.fileUpload.click();
+ },
+ onFileInputChange(e) {
+ this.$emit('change', e.target.files);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="gl-w-full gl-relative"
+ @dragstart.prevent.stop
+ @dragend.prevent.stop
+ @dragover.prevent.stop
+ @dragenter.prevent.stop="ondragenter"
+ @dragleave.prevent.stop="ondragleave"
+ @drop.prevent.stop="ondrop"
+ >
+ <slot>
+ <button
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
+ @click="openFileUpload"
+ >
+ <div
+ :class="{ 'gl-flex-direction-column': displayAsCard }"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center"
+ data-testid="dropzone-area"
+ >
+ <gl-icon name="upload" :size="iconStyles.size" :class="iconStyles.class" />
+ <p class="gl-mb-0">
+ <slot name="upload-text" :openFileUpload="openFileUpload">
+ <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} files to attach')">
+ <template #link="{ content }">
+ <gl-link @click.stop="openFileUpload">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </slot>
+ </p>
+ </div>
+ </button>
+
+ <input
+ ref="fileUpload"
+ type="file"
+ name="upload_file"
+ :accept="validFileMimetypes"
+ class="hide"
+ multiple
+ @change="onFileInputChange"
+ />
+ </slot>
+ <transition name="upload-dropzone-fade">
+ <div
+ v-show="dragging && !enableDragBehavior"
+ class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
+ >
+ <div v-show="!isDragDataValid" class="mw-50 gl-text-center">
+ <slot name="invalid-drag-data-slot">
+ <h3 :class="{ 'gl-font-base gl-display-inline': !displayAsCard }">
+ {{ __('Oh no!') }}
+ </h3>
+ <span>{{
+ __(
+ 'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.',
+ )
+ }}</span>
+ </slot>
+ </div>
+ <div v-show="isDragDataValid" class="mw-50 gl-text-center">
+ <slot name="valid-drag-data-slot">
+ <h3 :class="{ 'gl-font-base gl-display-inline': !displayAsCard }">
+ {{ __('Incoming!') }}
+ </h3>
+ <span>{{ dropToStartMessage }}</span>
+ </slot>
+ </div>
+ </div>
+ </transition>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/utils.js b/app/assets/javascripts/vue_shared/components/upload_dropzone/utils.js
new file mode 100644
index 00000000000..cf51a570d46
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/utils.js
@@ -0,0 +1,4 @@
+import { VALID_IMAGE_FILE_MIMETYPE } from './constants';
+
+export const isValidImage = ({ type }) =>
+ (type.match(VALID_IMAGE_FILE_MIMETYPE.regex) || []).length > 0;
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 3f5738b2b93..2ab4c55d9b0 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
@@ -6,6 +6,7 @@ import {
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlIcon,
} from '@gitlab/ui';
+import UserAvailabilityStatus from '~/set_status_modal/components/user_availability_status.vue';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji';
@@ -25,6 +26,7 @@ export default {
GlPopover,
GlSkeletonLoading,
UserAvatarImage,
+ UserAvailabilityStatus,
},
props: {
target: {
@@ -63,6 +65,9 @@ export default {
websiteUrl.length
);
},
+ availabilityStatus() {
+ return this.user?.status?.availability || null;
+ },
},
};
</script>
@@ -89,6 +94,10 @@ export default {
<div class="gl-mb-3">
<h5 class="gl-m-0">
{{ user.name }}
+ <user-availability-status
+ v-if="availabilityStatus"
+ :availability="availabilityStatus"
+ />
</h5>
<span class="gl-text-gray-500">@{{ user.username }}</span>
</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 877414519f7..dbb1a075e76 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -169,7 +169,7 @@ export default {
</script>
<template>
- <div class="d-inline-block gl-ml-3">
+ <div class="gl-sm-ml-3">
<actions-button
:actions="actions"
:selected-key="selection"
diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js
new file mode 100644
index 00000000000..09bec78edcc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/directives/validation.js
@@ -0,0 +1,132 @@
+import { merge } from 'lodash';
+import { s__ } from '~/locale';
+
+/**
+ * Validation messages will take priority based on the property order.
+ *
+ * For example:
+ * { valueMissing: {...}, urlTypeMismatch: {...} }
+ *
+ * `valueMissing` will be displayed the user has entered a value
+ * after that, if the input is not a valid URL then `urlTypeMismatch` will show
+ */
+const defaultFeedbackMap = {
+ valueMissing: {
+ isInvalid: el => el.validity?.valueMissing,
+ message: s__('Please fill out this field.'),
+ },
+ urlTypeMismatch: {
+ isInvalid: el => el.type === 'url' && el.validity?.typeMismatch,
+ message: s__('Please enter a valid URL format, ex: http://www.example.com/home'),
+ },
+};
+
+const getFeedbackForElement = (feedbackMap, el) =>
+ Object.values(feedbackMap).find(f => f.isInvalid(el))?.message || el.validationMessage;
+
+const focusFirstInvalidInput = e => {
+ const { target: formEl } = e;
+ const invalidInput = formEl.querySelector('input:invalid');
+
+ if (invalidInput) {
+ invalidInput.focus();
+ }
+};
+
+const isEveryFieldValid = form => Object.values(form.fields).every(({ state }) => state === true);
+
+const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = false }) => {
+ const { form } = context;
+ const { name } = el;
+
+ if (!name) {
+ if (process.env.NODE_ENV === 'development') {
+ // eslint-disable-next-line no-console
+ console.warn(
+ '[gitlab] the validation directive requires the given input to have "name" attribute',
+ );
+ }
+ return;
+ }
+
+ const formField = form.fields[name];
+ const isValid = el.checkValidity();
+
+ // This makes sure we always report valid fields - this can be useful for cases where the consuming
+ // component's logic depends on certain fields being in a valid state.
+ // Invalid input, on the other hand, should only be reported once we want to display feedback to the user.
+ // (eg.: After a field has been touched and moved away from, a submit-button has been clicked, ...)
+ formField.state = reportInvalidInput ? isValid : isValid || null;
+ formField.feedback = reportInvalidInput ? getFeedbackForElement(feedbackMap, el) : '';
+
+ form.state = isEveryFieldValid(form);
+};
+
+/**
+ * Takes an object that allows to add or change custom feedback messages.
+ *
+ * The passed in object will be merged with the built-in feedback
+ * so it is possible to override a built-in message.
+ *
+ * @example
+ * validate({
+ * tooLong: {
+ * check: el => el.validity.tooLong === true,
+ * message: 'Your custom feedback'
+ * }
+ * })
+ *
+ * @example
+ * validate({
+ * valueMissing: {
+ * message: 'Your custom feedback'
+ * }
+ * })
+ *
+ * @param {Object<string, { message: string, isValid: ?function}>} customFeedbackMap
+ * @returns {{ inserted: function, update: function }} validateDirective
+ */
+export default function(customFeedbackMap = {}) {
+ const feedbackMap = merge(defaultFeedbackMap, customFeedbackMap);
+ const elDataMap = new WeakMap();
+
+ return {
+ inserted(el, binding, { context }) {
+ const { arg: showGlobalValidation } = binding;
+ const { form: formEl } = el;
+
+ const validate = createValidator(context, feedbackMap);
+ const elData = { validate, isTouched: false, isBlurred: false };
+
+ elDataMap.set(el, elData);
+
+ el.addEventListener('input', function markAsTouched() {
+ elData.isTouched = true;
+ // once the element has been marked as touched we can stop listening on the 'input' event
+ el.removeEventListener('input', markAsTouched);
+ });
+
+ el.addEventListener('blur', function markAsBlurred({ target }) {
+ if (elData.isTouched) {
+ elData.isBlurred = true;
+ validate({ el: target, reportInvalidInput: true });
+ // this event handler can be removed, since the live-feedback in `update` takes over
+ el.removeEventListener('blur', markAsBlurred);
+ }
+ });
+
+ if (formEl) {
+ formEl.addEventListener('submit', focusFirstInvalidInput);
+ }
+
+ validate({ el, reportInvalidInput: showGlobalValidation });
+ },
+ update(el, binding) {
+ const { arg: showGlobalValidation } = binding;
+ const { validate, isTouched, isBlurred } = elDataMap.get(el);
+ const showValidationFeedback = showGlobalValidation || (isTouched && isBlurred);
+
+ validate({ el, reportInvalidInput: showValidationFeedback });
+ },
+ };
+}
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
index c0fc055a01b..56da2637825 100644
--- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
@@ -1,7 +1,6 @@
import { isEmpty } from 'lodash';
import { sprintf, __ } from '~/locale';
import { formatDate } from '~/lib/utils/datetime_utility';
-import tooltip from '~/vue_shared/directives/tooltip';
import timeagoMixin from '~/vue_shared/mixins/timeago';
const mixins = {
@@ -99,9 +98,6 @@ const mixins = {
default: () => ({}),
},
},
- directives: {
- tooltip,
- },
mixins: [timeagoMixin],
computed: {
hasState() {
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
new file mode 100644
index 00000000000..2f87c4e7878
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -0,0 +1,3 @@
+export const FEEDBACK_TYPE_DISMISSAL = 'dismissal';
+export const FEEDBACK_TYPE_ISSUE = 'issue';
+export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request';
diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
index d5696e3c8cf..89253cc7116 100644
--- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
@@ -3,6 +3,7 @@ import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import ReportSection from '~/reports/components/report_section.vue';
import { status } from '~/reports/constants';
import { s__ } from '~/locale';
+import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import Flash from '~/flash';
import Api from '~/api';
@@ -52,12 +53,27 @@ export default {
});
},
methods: {
- checkHasSecurityReports(reportTypes) {
- return Api.pipelineJobs(this.projectId, this.pipelineId).then(({ data: jobs }) =>
- jobs.some(({ artifacts = [] }) =>
+ async checkHasSecurityReports(reportTypes) {
+ let page = 1;
+ while (page) {
+ // eslint-disable-next-line no-await-in-loop
+ const { data: jobs, headers } = await Api.pipelineJobs(this.projectId, this.pipelineId, {
+ per_page: 100,
+ page,
+ });
+
+ const hasSecurityReports = jobs.some(({ artifacts = [] }) =>
artifacts.some(({ file_type }) => reportTypes.includes(file_type)),
- ),
- );
+ );
+
+ if (hasSecurityReports) {
+ return true;
+ }
+
+ page = parseIntPagination(normalizeHeaders(headers)).nextPage;
+ }
+
+ return false;
},
activatePipelinesTab() {
if (window.mrTabs) {
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
new file mode 100644
index 00000000000..22a45341c51
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
@@ -0,0 +1,24 @@
+import * as types from './mutation_types';
+import { fetchDiffData } from '../../utils';
+
+export const setDiffEndpoint = ({ commit }, path) => commit(types.SET_DIFF_ENDPOINT, path);
+
+export const requestDiff = ({ commit }) => commit(types.REQUEST_DIFF);
+
+export const receiveDiffSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_DIFF_SUCCESS, response);
+
+export const receiveDiffError = ({ commit }, response) =>
+ commit(types.RECEIVE_DIFF_ERROR, response);
+
+export const fetchDiff = ({ state, rootState, dispatch }) => {
+ dispatch('requestDiff');
+
+ return fetchDiffData(rootState, state.paths.diffEndpoint, 'sast')
+ .then(data => {
+ dispatch('receiveDiffSuccess', data);
+ })
+ .catch(() => {
+ dispatch('receiveDiffError');
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js
new file mode 100644
index 00000000000..68c81bb4509
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import mutations from './mutations';
+import * as actions from './actions';
+
+export default {
+ namespaced: true,
+ state,
+ mutations,
+ actions,
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js
new file mode 100644
index 00000000000..aacec0fb679
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js
@@ -0,0 +1,4 @@
+export const RECEIVE_DIFF_SUCCESS = 'RECEIVE_DIFF_SUCCESS';
+export const RECEIVE_DIFF_ERROR = 'RECEIVE_DIFF_ERROR';
+export const REQUEST_DIFF = 'REQUEST_DIFF';
+export const SET_DIFF_ENDPOINT = 'SET_DIFF_ENDPOINT';
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js
new file mode 100644
index 00000000000..5f6153ca3b1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import * as types from './mutation_types';
+import { parseDiff } from '../../utils';
+
+export default {
+ [types.SET_DIFF_ENDPOINT](state, path) {
+ Vue.set(state.paths, 'diffEndpoint', path);
+ },
+
+ [types.REQUEST_DIFF](state) {
+ state.isLoading = true;
+ },
+
+ [types.RECEIVE_DIFF_SUCCESS](state, { diff, enrichData }) {
+ const { added, fixed, existing } = parseDiff(diff, enrichData);
+ const baseReportOutofDate = diff.base_report_out_of_date || false;
+ const hasBaseReport = Boolean(diff.base_report_created_at);
+
+ state.isLoading = false;
+ state.newIssues = added;
+ state.resolvedIssues = fixed;
+ state.allIssues = existing;
+ state.baseReportOutofDate = baseReportOutofDate;
+ state.hasBaseReport = hasBaseReport;
+ },
+
+ [types.RECEIVE_DIFF_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js
new file mode 100644
index 00000000000..e860e3af924
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js
@@ -0,0 +1,16 @@
+export default () => ({
+ paths: {
+ head: null,
+ base: null,
+ diffEndpoint: null,
+ },
+
+ isLoading: false,
+ hasError: false,
+
+ newIssues: [],
+ resolvedIssues: [],
+ allIssues: [],
+ baseReportOutofDate: false,
+ hasBaseReport: false,
+});
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
new file mode 100644
index 00000000000..c9da824613d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
@@ -0,0 +1,24 @@
+import { fetchDiffData } from '../../utils';
+import * as types from './mutation_types';
+
+export const setDiffEndpoint = ({ commit }, path) => commit(types.SET_DIFF_ENDPOINT, path);
+
+export const requestDiff = ({ commit }) => commit(types.REQUEST_DIFF);
+
+export const receiveDiffSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_DIFF_SUCCESS, response);
+
+export const receiveDiffError = ({ commit }, response) =>
+ commit(types.RECEIVE_DIFF_ERROR, response);
+
+export const fetchDiff = ({ state, rootState, dispatch }) => {
+ dispatch('requestDiff');
+
+ return fetchDiffData(rootState, state.paths.diffEndpoint, 'secret_detection')
+ .then(data => {
+ dispatch('receiveDiffSuccess', data);
+ })
+ .catch(() => {
+ dispatch('receiveDiffError');
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js
new file mode 100644
index 00000000000..68c81bb4509
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import mutations from './mutations';
+import * as actions from './actions';
+
+export default {
+ namespaced: true,
+ state,
+ mutations,
+ actions,
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js
new file mode 100644
index 00000000000..aacec0fb679
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js
@@ -0,0 +1,4 @@
+export const RECEIVE_DIFF_SUCCESS = 'RECEIVE_DIFF_SUCCESS';
+export const RECEIVE_DIFF_ERROR = 'RECEIVE_DIFF_ERROR';
+export const REQUEST_DIFF = 'REQUEST_DIFF';
+export const SET_DIFF_ENDPOINT = 'SET_DIFF_ENDPOINT';
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js
new file mode 100644
index 00000000000..ee943b0621c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js
@@ -0,0 +1,30 @@
+import { parseDiff } from '~/vue_shared/security_reports/store/utils';
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_DIFF_ENDPOINT](state, path) {
+ state.paths.diffEndpoint = path;
+ },
+
+ [types.REQUEST_DIFF](state) {
+ state.isLoading = true;
+ },
+
+ [types.RECEIVE_DIFF_SUCCESS](state, { diff, enrichData }) {
+ const { added, fixed, existing } = parseDiff(diff, enrichData);
+ const baseReportOutofDate = diff.base_report_out_of_date || false;
+ const hasBaseReport = Boolean(diff.base_report_created_at);
+
+ state.isLoading = false;
+ state.newIssues = added;
+ state.resolvedIssues = fixed;
+ state.allIssues = existing;
+ state.baseReportOutofDate = baseReportOutofDate;
+ state.hasBaseReport = hasBaseReport;
+ },
+
+ [types.RECEIVE_DIFF_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js
new file mode 100644
index 00000000000..e860e3af924
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js
@@ -0,0 +1,16 @@
+export default () => ({
+ paths: {
+ head: null,
+ base: null,
+ diffEndpoint: null,
+ },
+
+ isLoading: false,
+ hasError: false,
+
+ newIssues: [],
+ resolvedIssues: [],
+ allIssues: [],
+ baseReportOutofDate: false,
+ hasBaseReport: false,
+});
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js
new file mode 100644
index 00000000000..6e50efae741
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js
@@ -0,0 +1,75 @@
+import pollUntilComplete from '~/lib/utils/poll_until_complete';
+import axios from '~/lib/utils/axios_utils';
+import {
+ FEEDBACK_TYPE_DISMISSAL,
+ FEEDBACK_TYPE_ISSUE,
+ FEEDBACK_TYPE_MERGE_REQUEST,
+} from '../constants';
+
+export const fetchDiffData = (state, endpoint, category) => {
+ const requests = [pollUntilComplete(endpoint)];
+
+ if (state.canReadVulnerabilityFeedback) {
+ requests.push(axios.get(state.vulnerabilityFeedbackPath, { params: { category } }));
+ }
+
+ return Promise.all(requests).then(([diffResponse, enrichResponse]) => ({
+ diff: diffResponse.data,
+ enrichData: enrichResponse?.data ?? [],
+ }));
+};
+
+/**
+ * Returns given vulnerability enriched with the corresponding
+ * feedback (`dismissal` or `issue` type)
+ * @param {Object} vulnerability
+ * @param {Array} feedback
+ */
+export const enrichVulnerabilityWithFeedback = (vulnerability, feedback = []) =>
+ feedback
+ .filter(fb => fb.project_fingerprint === vulnerability.project_fingerprint)
+ .reduce((vuln, fb) => {
+ if (fb.feedback_type === FEEDBACK_TYPE_DISMISSAL) {
+ return {
+ ...vuln,
+ isDismissed: true,
+ dismissalFeedback: fb,
+ };
+ }
+ if (fb.feedback_type === FEEDBACK_TYPE_ISSUE && fb.issue_iid) {
+ return {
+ ...vuln,
+ hasIssue: true,
+ issue_feedback: fb,
+ };
+ }
+ if (fb.feedback_type === FEEDBACK_TYPE_MERGE_REQUEST && fb.merge_request_iid) {
+ return {
+ ...vuln,
+ hasMergeRequest: true,
+ merge_request_feedback: fb,
+ };
+ }
+ return vuln;
+ }, vulnerability);
+
+/**
+ * Generates the added, fixed, and existing vulnerabilities from the API report.
+ *
+ * @param {Object} diff The original reports.
+ * @param {Object} enrichData Feedback data to add to the reports.
+ * @returns {Object}
+ */
+export const parseDiff = (diff, enrichData) => {
+ const enrichVulnerability = vulnerability => ({
+ ...enrichVulnerabilityWithFeedback(vulnerability, enrichData),
+ category: vulnerability.report_type,
+ title: vulnerability.message || vulnerability.name,
+ });
+
+ return {
+ added: diff.added ? diff.added.map(enrichVulnerability) : [],
+ fixed: diff.fixed ? diff.fixed.map(enrichVulnerability) : [],
+ existing: diff.existing ? diff.existing.map(enrichVulnerability) : [],
+ };
+};