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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/vue_shared')
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue9
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue3
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/chronic_duration_input.vue133
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_modal.vue72
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/delete_label_modal.vue81
-rw-r--r--app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue67
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_alert.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/dom_element_listener.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js113
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js15
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql15
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql16
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue129
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue138
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue1
-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/filtered_search_bar/tokens/weight_token.vue66
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js27
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue127
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue61
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue107
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue91
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue201
-rw-r--r--app/assets/javascripts/vue_shared/components/line_numbers.vue57
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue93
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js40
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue103
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/list_item.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/metadata_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue101
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue45
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue55
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_editor.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer.vue88
-rw-r--r--app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js38
-rw-r--r--app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue148
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue69
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js88
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue85
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue44
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue128
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue35
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue303
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue363
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue67
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/constants.js51
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue194
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue52
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue15
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue167
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue152
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue162
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue101
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/constants.js5
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/event_hub.js3
-rw-r--r--app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue86
-rw-r--r--app/assets/javascripts/vue_shared/issuable/sidebar/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/mixins/issuable.js14
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js214
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql2
93 files changed, 3140 insertions, 1737 deletions
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
index c24318cb9ad..489d4afa41f 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
@@ -220,16 +220,17 @@ export default {
class="gl-text-gray-900 gl-mb-2 gl-line-height-20 gl-display-flex gl-justify-content-space-between"
>
{{ __('Assignee') }}
- <a
+ <gl-button
v-if="isEditable"
ref="editButton"
- class="btn-link"
- href="#"
+ category="tertiary"
+ size="small"
+ class="gl-text-black-normal!"
@click="toggleFormDropdown"
@keydown.esc="hideDropdown"
>
{{ __('Edit') }}
- </a>
+ </gl-button>
</p>
<gl-dropdown
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
index eaa5fc5af04..c512585b980 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
@@ -100,7 +100,8 @@ export default {
<gl-button
v-if="isEditable"
class="gl-text-black-normal!"
- variant="link"
+ category="tertiary"
+ size="small"
@click="toggleFormDropdown"
@keydown.esc="hideDropdown"
>
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
index f0095abfca1..0460d250f75 100644
--- a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
@@ -2,6 +2,7 @@ mutation createAlertIssue($projectPath: ID!, $iid: String!) {
createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) {
errors
issue {
+ id
iid
webUrl
}
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql
index 0c26fcc0ab2..0ea209ffd39 100644
--- a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql
@@ -3,6 +3,7 @@
query alertDetailsAssignees($fullPath: ID!, $alertId: String) {
project(fullPath: $fullPath) {
+ id
alertManagementAlerts(iid: $alertId) {
nodes {
...AlertDetailItem
diff --git a/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue
new file mode 100644
index 00000000000..ffbcdefc924
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue
@@ -0,0 +1,133 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { GlFormInput } from '@gitlab/ui';
+import {
+ DurationParseError,
+ outputChronicDuration,
+ parseChronicDuration,
+} from '~/chronic_duration';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlFormInput,
+ },
+ model: {
+ prop: 'value',
+ event: 'change',
+ },
+ props: {
+ value: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ name: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ integerRequired: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ numberData: this.value,
+ humanReadableData: this.convertDuration(this.value),
+ isValueValid: this.value === null ? null : true,
+ };
+ },
+ computed: {
+ numberValue: {
+ get() {
+ return this.numberData;
+ },
+ set(value) {
+ if (this.numberData !== value) {
+ this.numberData = value;
+ this.humanReadableData = this.convertDuration(value);
+ this.isValueValid = value === null ? null : true;
+ }
+ this.emitEvents();
+ },
+ },
+ humanReadableValue: {
+ get() {
+ return this.humanReadableData;
+ },
+ set(value) {
+ this.humanReadableData = value;
+ try {
+ if (value === '') {
+ this.numberData = null;
+ this.isValueValid = null;
+ } else {
+ this.numberData = parseChronicDuration(value, {
+ keepZero: true,
+ raiseExceptions: true,
+ });
+ this.isValueValid = true;
+ }
+ } catch (e) {
+ if (e instanceof DurationParseError) {
+ this.isValueValid = false;
+ } else {
+ Sentry.captureException(e);
+ }
+ }
+ this.emitEvents(true);
+ },
+ },
+ isValidDecimal() {
+ return !this.integerRequired || this.numberData === null || Number.isInteger(this.numberData);
+ },
+ feedback() {
+ if (this.isValueValid === false) {
+ return this.$options.i18n.INVALID_INPUT_FEEDBACK;
+ }
+ if (!this.isValidDecimal) {
+ return this.$options.i18n.INVALID_DECIMAL_FEEDBACK;
+ }
+ return '';
+ },
+ },
+ i18n: {
+ INVALID_INPUT_FEEDBACK: __('Please enter a valid time interval'),
+ INVALID_DECIMAL_FEEDBACK: __('An integer value is required for seconds'),
+ },
+ watch: {
+ value() {
+ this.numberValue = this.value;
+ },
+ },
+ mounted() {
+ this.emitEvents();
+ },
+ methods: {
+ convertDuration(value) {
+ return value === null ? '' : outputChronicDuration(value);
+ },
+ emitEvents(emitChange = false) {
+ if (emitChange && this.isValueValid !== false && this.isValidDecimal) {
+ this.$emit('change', this.numberData);
+ }
+ const { feedback } = this;
+ this.$refs.text.$el.setCustomValidity(feedback);
+ this.$refs.hidden.setCustomValidity(feedback);
+ this.$emit('valid', {
+ valid: this.isValueValid && this.isValidDecimal,
+ feedback,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-form-input ref="text" v-bind="$attrs" v-model="humanReadableValue" />
+ <input ref="hidden" type="hidden" :name="name" :value="numberValue" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index fe329b18f30..400be3ef688 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -66,6 +66,11 @@ export default {
required: false,
default: 'medium',
},
+ variant: {
+ type: String,
+ required: false,
+ default: 'default',
+ },
},
computed: {
clipboardText() {
@@ -92,6 +97,7 @@ export default {
:size="size"
icon="copy-to-clipboard"
:aria-label="__('Copy this value')"
+ :variant="variant"
v-on="$listeners"
>
<slot></slot>
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index 5f50a699034..ebbc1bfb037 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective, GlLink, GlIcon } from '@gitlab/ui';
import { isString, isEmpty } from 'lodash';
import { __, sprintf } from '~/locale';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from './user_avatar/user_avatar_link.vue';
export default {
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
index 4c07cf44fed..f93415ced45 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
@@ -26,6 +26,11 @@ export default {
type: String,
required: true,
},
+ buttonClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
buttonTestid: {
type: String,
required: false,
@@ -39,7 +44,7 @@ export default {
<div>
<gl-button
v-gl-modal="$options.modalId"
- class="gl-button"
+ :class="buttonClass"
variant="danger"
:disabled="disabled"
:data-testid="buttonTestid"
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
index 30c96daf7e3..5bbe44b20b3 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
@@ -47,7 +47,7 @@ export default {
actionPrimary() {
return {
text: this.confirmButtonText,
- attributes: [{ variant: 'danger', disabled: !this.isValid }],
+ attributes: [{ variant: 'danger', disabled: !this.isValid, class: 'qa-confirm-button' }],
};
},
},
@@ -95,7 +95,7 @@ export default {
<gl-form-input
id="confirm_name_input"
v-model="confirmationPhrase"
- class="form-control"
+ class="form-control qa-confirm-input"
data-testid="confirm-danger-input"
type="text"
/>
diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
index 7c1d3772acd..72504e5bc50 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
@@ -2,10 +2,13 @@
import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import csrf from '~/lib/utils/csrf';
+import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from './confirm_modal_eventhub';
+import DomElementListener from './dom_element_listener.vue';
export default {
components: {
GlModal,
+ DomElementListener,
},
directives: {
SafeHtml,
@@ -30,18 +33,35 @@ export default {
};
},
mounted() {
- document.querySelectorAll(this.selector).forEach((button) => {
- button.addEventListener('click', (e) => {
- e.preventDefault();
-
- this.path = button.dataset.path;
- this.method = button.dataset.method;
- this.modalAttributes = JSON.parse(button.dataset.modalAttributes);
- this.openModal();
- });
- });
+ eventHub.$on(EVENT_OPEN_CONFIRM_MODAL, this.onOpenEvent);
+ },
+ destroyed() {
+ eventHub.$off(EVENT_OPEN_CONFIRM_MODAL, this.onOpenEvent);
},
methods: {
+ onButtonPress(e) {
+ const element = e.currentTarget;
+
+ if (!element.dataset.path) {
+ return;
+ }
+
+ const modalAttributes = element.dataset.modalAttributes
+ ? JSON.parse(element.dataset.modalAttributes)
+ : {};
+
+ this.onOpenEvent({
+ path: element.dataset.path,
+ method: element.dataset.method,
+ modalAttributes,
+ });
+ },
+ onOpenEvent({ path, method, modalAttributes }) {
+ this.path = path;
+ this.method = method;
+ this.modalAttributes = modalAttributes;
+ this.openModal();
+ },
openModal() {
this.$refs.modal.show();
},
@@ -61,21 +81,23 @@ export default {
</script>
<template>
- <gl-modal
- ref="modal"
- :modal-id="modalId"
- v-bind="modalAttributes"
- @primary="submitModal"
- @cancel="closeModal"
- >
- <form ref="form" :action="path" method="post">
- <!-- Rails workaround for <form method="delete" />
+ <dom-element-listener :selector="selector" @click.prevent="onButtonPress">
+ <gl-modal
+ ref="modal"
+ :modal-id="modalId"
+ v-bind="modalAttributes"
+ @primary="submitModal"
+ @cancel="closeModal"
+ >
+ <form ref="form" :action="path" method="post">
+ <!-- Rails workaround for <form method="delete" />
https://github.com/rails/rails/blob/master/actionview/app/assets/javascripts/rails-ujs/features/method.coffee
-->
- <input type="hidden" name="_method" :value="method" />
- <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
- <div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div>
- <div v-else>{{ modalAttributes.message }}</div>
- </form>
- </gl-modal>
+ <input type="hidden" name="_method" :value="method" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ <div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div>
+ <div v-else>{{ modalAttributes.message }}</div>
+ </form>
+ </gl-modal>
+ </dom-element-listener>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js b/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js
new file mode 100644
index 00000000000..f8d9d410ace
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js
@@ -0,0 +1,5 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
+
+export const EVENT_OPEN_CONFIRM_MODAL = Symbol('OPEN');
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
index 1a96cabf755..e546ca57c5e 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
@@ -3,7 +3,7 @@ import { GlIcon, GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitl
import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range';
import { __, sprintf } from '~/locale';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import DateTimePickerInput from './date_time_picker_input.vue';
import {
defaultTimeRanges,
diff --git a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue b/app/assets/javascripts/vue_shared/components/delete_label_modal.vue
deleted file mode 100644
index 1ff0938d086..00000000000
--- a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<script>
-import { GlModal, GlSprintf, GlButton } from '@gitlab/ui';
-import { uniqueId } from 'lodash';
-
-export default {
- components: {
- GlModal,
- GlSprintf,
- GlButton,
- },
- props: {
- selector: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- labelName: '',
- subjectName: '',
- destroyPath: '',
- modalId: uniqueId('modal-delete-label-'),
- };
- },
- mounted() {
- document.querySelectorAll(this.selector).forEach((button) => {
- button.addEventListener('click', (e) => {
- e.preventDefault();
-
- const { labelName, subjectName, destroyPath } = button.dataset;
- this.labelName = labelName;
- this.subjectName = subjectName;
- this.destroyPath = destroyPath;
- this.openModal();
- });
- });
- },
- methods: {
- openModal() {
- this.$refs.modal.show();
- },
- closeModal() {
- this.$refs.modal.hide();
- },
- },
-};
-</script>
-
-<template>
- <gl-modal ref="modal" :modal-id="modalId">
- <template #modal-title>
- <gl-sprintf :message="__('Delete label: %{labelName}')">
- <template #labelName>
- {{ labelName }}
- </template>
- </gl-sprintf>
- </template>
- <gl-sprintf
- :message="
- __(
- `%{strongStart}${labelName}%{strongEnd} will be permanently deleted from ${subjectName}. This cannot be undone.`,
- )
- "
- >
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- <template #modal-footer>
- <gl-button category="secondary" @click="closeModal">{{ __('Cancel') }}</gl-button>
- <gl-button
- category="primary"
- variant="danger"
- :href="destroyPath"
- data-method="delete"
- data-testid="delete-button"
- >{{ __('Delete label') }}</gl-button
- >
- </template>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue
new file mode 100644
index 00000000000..cb038a8c4e1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+
+export default {
+ name: 'DesignNotePin',
+ components: {
+ GlIcon,
+ },
+ props: {
+ position: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ label: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ isResolved: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isInactive: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ isNewNote() {
+ return this.label === null;
+ },
+ pinLabel() {
+ return this.isNewNote
+ ? __('Comment form position')
+ : sprintf(__("Comment '%{label}' position"), { label: this.label });
+ },
+ },
+};
+</script>
+
+<template>
+ <button
+ :style="position"
+ :aria-label="pinLabel"
+ :class="{
+ 'btn-transparent comment-indicator': isNewNote,
+ 'js-image-badge design-note-pin': !isNewNote,
+ resolved: isResolved,
+ inactive: isInactive,
+ 'gl-absolute': position,
+ }"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm"
+ type="button"
+ @mousedown="$emit('mousedown', $event)"
+ @mouseup="$emit('mouseup', $event)"
+ @click="$emit('click', $event)"
+ >
+ <gl-icon v-if="isNewNote" name="image-comment-dark" :size="24" />
+ <template v-else>
+ {{ label }}
+ </template>
+ </button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue
index 52371e42ba1..0621ec14c6c 100644
--- a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue
+++ b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue
@@ -24,6 +24,7 @@ export default {
methods: {
dismiss() {
this.isDismissed = true;
+ this.$emit('alertDismissed');
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/dom_element_listener.vue b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue
new file mode 100644
index 00000000000..ca427ed4897
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue
@@ -0,0 +1,28 @@
+<script>
+export default {
+ props: {
+ selector: {
+ type: String,
+ required: true,
+ },
+ },
+ mounted() {
+ this.disposables = Array.from(document.querySelectorAll(this.selector)).flatMap((button) => {
+ return Object.entries(this.$listeners).map(([key, value]) => {
+ button.addEventListener(key, value);
+ return () => {
+ button.removeEventListener(key, value);
+ };
+ });
+ });
+ },
+ destroyed() {
+ this.disposables.forEach((x) => {
+ x();
+ });
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
index e1e71639115..8686d317c8a 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
+++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
@@ -6,15 +6,10 @@ const fileExtensionIcons = {
jade: 'pug',
pug: 'pug',
md: 'markdown',
- 'md.rendered': 'markdown',
markdown: 'markdown',
- 'markdown.rendered': 'markdown',
mdown: 'markdown',
- 'mdown.rendered': 'markdown',
mkd: 'markdown',
- 'mkd.rendered': 'markdown',
mkdn: 'markdown',
- 'mkdn.rendered': 'markdown',
rst: 'markdown',
blink: 'blink',
css: 'css',
@@ -23,7 +18,6 @@ const fileExtensionIcons = {
less: 'less',
json: 'json',
yaml: 'yaml',
- 'YAML-tmLanguage': 'yaml',
yml: 'yaml',
xml: 'xml',
plist: 'xml',
@@ -85,10 +79,7 @@ const fileExtensionIcons = {
props: 'settings',
toml: 'settings',
prefs: 'settings',
- 'sln.dotsettings': 'settings',
- 'sln.dotsettings.user': 'settings',
ts: 'typescript',
- 'd.ts': 'typescript-def',
marko: 'markojs',
pdf: 'pdf',
xlsx: 'table',
@@ -99,7 +90,6 @@ const fileExtensionIcons = {
vscodeignore: 'vscode',
vsixmanifest: 'vscode',
vsix: 'vscode',
- 'code-workplace': 'vscode',
suo: 'visualstudio',
sln: 'visualstudio',
csproj: 'visualstudio',
@@ -118,7 +108,6 @@ const fileExtensionIcons = {
xz: 'zip',
bzip2: 'zip',
gzip: 'zip',
- '7z': 'zip',
rar: 'zip',
tgz: 'zip',
exe: 'exe',
@@ -129,7 +118,6 @@ const fileExtensionIcons = {
c: 'c',
m: 'c',
h: 'h',
- 'c++': 'cpp',
cc: 'cpp',
cpp: 'cpp',
mm: 'cpp',
@@ -231,7 +219,6 @@ const fileExtensionIcons = {
m2v: 'movie',
vdi: 'virtual',
vbox: 'virtual',
- 'vbox-prev': 'virtual',
ics: 'email',
mp3: 'music',
flac: 'music',
@@ -277,44 +264,12 @@ const fileExtensionIcons = {
ml: 'ocaml',
mli: 'ocaml',
cmx: 'ocaml',
- 'js.map': 'javascript-map',
- 'css.map': 'css-map',
lock: 'lock',
hbs: 'handlebars',
mustache: 'handlebars',
pl: 'perl',
pm: 'perl',
hx: 'haxe',
- 'spec.ts': 'test-ts',
- 'test.ts': 'test-ts',
- 'ts.snap': 'test-ts',
- 'spec.tsx': 'test-jsx',
- 'test.tsx': 'test-jsx',
- 'tsx.snap': 'test-jsx',
- 'spec.jsx': 'test-jsx',
- 'test.jsx': 'test-jsx',
- 'jsx.snap': 'test-jsx',
- 'spec.js': 'test-js',
- 'test.js': 'test-js',
- 'js.snap': 'test-js',
- 'routing.ts': 'angular-routing',
- 'routing.js': 'angular-routing',
- 'module.ts': 'angular',
- 'module.js': 'angular',
- 'ng-template': 'angular',
- 'component.ts': 'angular-component',
- 'component.js': 'angular-component',
- 'guard.ts': 'angular-guard',
- 'guard.js': 'angular-guard',
- 'service.ts': 'angular-service',
- 'service.js': 'angular-service',
- 'pipe.ts': 'angular-pipe',
- 'pipe.js': 'angular-pipe',
- 'filter.js': 'angular-pipe',
- 'directive.ts': 'angular-directive',
- 'directive.js': 'angular-directive',
- 'resolver.ts': 'angular-resolver',
- 'resolver.js': 'angular-resolver',
pp: 'puppet',
ex: 'elixir',
exs: 'elixir',
@@ -345,11 +300,8 @@ const fileExtensionIcons = {
haml: 'haml',
yang: 'yang',
tf: 'terraform',
- 'tf.json': 'terraform',
tfvars: 'terraform',
tfstate: 'terraform',
- 'blade.php': 'laravel',
- 'inky.php': 'laravel',
applescript: 'applescript',
cake: 'cake',
feature: 'cucumber',
@@ -376,16 +328,68 @@ const fileExtensionIcons = {
kv: 'kivy',
graphcool: 'graphcool',
sbt: 'sbt',
+ cr: 'crystal',
+ cu: 'cuda',
+ cuh: 'cuda',
+ log: 'log',
+};
+
+const twoFileExtensionIcons = {
+ 'gradle.kts': 'gradle',
+ 'md.rendered': 'markdown',
+ 'markdown.rendered': 'markdown',
+ 'mdown.rendered': 'markdown',
+ 'mkd.rendered': 'markdown',
+ 'mkdn.rendered': 'markdown',
+ 'YAML-tmLanguage': 'yaml',
+ 'sln.dotsettings': 'settings',
+ 'sln.dotsettings.user': 'settings',
+ 'd.ts': 'typescript-def',
+ 'code-workplace': 'vscode',
+ '7z': 'zip',
+ 'c++': 'cpp',
+ 'vbox-prev': 'virtual',
+ 'js.map': 'javascript-map',
+ 'css.map': 'css-map',
+ 'spec.ts': 'test-ts',
+ 'test.ts': 'test-ts',
+ 'ts.snap': 'test-ts',
+ 'spec.tsx': 'test-jsx',
+ 'test.tsx': 'test-jsx',
+ 'tsx.snap': 'test-jsx',
+ 'spec.jsx': 'test-jsx',
+ 'test.jsx': 'test-jsx',
+ 'jsx.snap': 'test-jsx',
+ 'spec.js': 'test-js',
+ 'test.js': 'test-js',
+ 'js.snap': 'test-js',
+ 'routing.ts': 'angular-routing',
+ 'routing.js': 'angular-routing',
+ 'module.ts': 'angular',
+ 'module.js': 'angular',
+ 'ng-template': 'angular',
+ 'component.ts': 'angular-component',
+ 'component.js': 'angular-component',
+ 'guard.ts': 'angular-guard',
+ 'guard.js': 'angular-guard',
+ 'service.ts': 'angular-service',
+ 'service.js': 'angular-service',
+ 'pipe.ts': 'angular-pipe',
+ 'pipe.js': 'angular-pipe',
+ 'filter.js': 'angular-pipe',
+ 'directive.ts': 'angular-directive',
+ 'directive.js': 'angular-directive',
+ 'resolver.ts': 'angular-resolver',
+ 'resolver.js': 'angular-resolver',
+ 'tf.json': 'terraform',
+ 'blade.php': 'laravel',
+ 'inky.php': 'laravel',
'reducer.ts': 'ngrx-reducer',
'rootReducer.ts': 'ngrx-reducer',
'state.ts': 'ngrx-state',
'actions.ts': 'ngrx-actions',
'effects.ts': 'ngrx-effects',
- cr: 'crystal',
'drone.yml': 'drone',
- cu: 'cuda',
- cuh: 'cuda',
- log: 'log',
};
const fileNameIcons = {
@@ -598,6 +602,9 @@ const fileNameIcons = {
export default function getIconForFile(name) {
return (
- fileNameIcons[name] || fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] || ''
+ fileNameIcons[name] ||
+ twoFileExtensionIcons[name ? name.split('.').slice(-2).join('.') : ''] ||
+ fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] ||
+ ''
);
}
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 0b0a416b7ef..2227047a909 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -146,6 +146,7 @@ export default {
ref="textOutput"
:style="levelIndentation"
class="file-row-name"
+ :title="file.name"
data-qa-selector="file_name_content"
:data-qa-file-name="file.name"
data-testid="file-row-name-container"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index d9290e86bca..810d9f782b9 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -2,7 +2,6 @@ import { __ } from '~/locale';
export const DEBOUNCE_DELAY = 200;
export const MAX_RECENT_TOKENS_SIZE = 3;
-export const WEIGHT_TOKEN_SUGGESTIONS_SIZE = 21;
export const FILTER_NONE = 'None';
export const FILTER_ANY = 'Any';
@@ -24,22 +23,11 @@ export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title:
export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') };
export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
-export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([
- { value: FILTER_CURRENT, text: __('Current') },
-]);
-
export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([
{ value: FILTER_UPCOMING, text: __('Upcoming'), title: __('Upcoming') },
{ value: FILTER_STARTED, text: __('Started'), title: __('Started') },
]);
-export const DEFAULT_MILESTONES_GRAPHQL = [
- { value: 'any', text: __('Any'), title: __('Any') },
- { value: 'none', text: __('None'), title: __('None') },
- { value: '#upcoming', text: __('Upcoming'), title: __('Upcoming') },
- { value: '#started', text: __('Started'), title: __('Started') },
-];
-
export const SortDirection = {
descending: 'descending',
ascending: 'ascending',
@@ -56,6 +44,3 @@ export const TOKEN_TITLE_TYPE = __('Type');
export const TOKEN_TITLE_RELEASE = __('Release');
export const TOKEN_TITLE_MY_REACTION = __('My-Reaction');
export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential');
-export const TOKEN_TITLE_ITERATION = __('Iteration');
-export const TOKEN_TITLE_EPIC = __('Epic');
-export const TOKEN_TITLE_WEIGHT = __('Weight');
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql
deleted file mode 100644
index 9e9bda8ad3e..00000000000
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql
+++ /dev/null
@@ -1,15 +0,0 @@
-fragment EpicNode on Epic {
- id
- iid
- group {
- fullPath
- }
- title
- state
- reference
- referencePath: reference(full: true)
- webPath
- webUrl
- createdAt
- closedAt
-}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql
deleted file mode 100644
index 4bb4b586fc9..00000000000
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql
+++ /dev/null
@@ -1,16 +0,0 @@
-#import "./epic.fragment.graphql"
-
-query searchEpics($fullPath: ID!, $search: String, $state: EpicState) {
- group(fullPath: $fullPath) {
- epics(
- search: $search
- state: $state
- includeAncestorGroups: true
- includeDescendantGroups: false
- ) {
- nodes {
- ...EpicNode
- }
- }
- }
-}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
index b3b3d5c88c6..06478a89721 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -87,7 +87,6 @@ export default {
:get-active-token-value="getActiveAuthor"
:default-suggestions="defaultAuthors"
:preloaded-suggestions="preloadedAuthors"
- :recent-suggestions-storage-key="config.recentSuggestionsStorageKey"
@fetch-suggestions="fetchAuthors"
v-on="$listeners"
>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index cee7c40aa83..bbc1888bc0b 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -4,12 +4,17 @@ import {
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlDropdownSectionHeader,
+ GlDropdownText,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { DEBOUNCE_DELAY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants';
-import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils';
+import {
+ getRecentlyUsedSuggestions,
+ setTokenValueToRecentlyUsed,
+ stripQuotes,
+} from '../filtered_search_utils';
export default {
components: {
@@ -17,6 +22,7 @@ export default {
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlDropdownSectionHeader,
+ GlDropdownText,
GlLoadingIcon,
},
props: {
@@ -57,11 +63,6 @@ export default {
required: false,
default: () => [],
},
- recentSuggestionsStorageKey: {
- type: String,
- required: false,
- default: '',
- },
valueIdentifier: {
type: String,
required: false,
@@ -76,14 +77,14 @@ export default {
data() {
return {
searchKey: '',
- recentSuggestions: this.recentSuggestionsStorageKey
- ? getRecentlyUsedSuggestions(this.recentSuggestionsStorageKey)
+ recentSuggestions: this.config.recentSuggestionsStorageKey
+ ? getRecentlyUsedSuggestions(this.config.recentSuggestionsStorageKey)
: [],
};
},
computed: {
isRecentSuggestionsEnabled() {
- return Boolean(this.recentSuggestionsStorageKey);
+ return Boolean(this.config.recentSuggestionsStorageKey);
},
recentTokenIds() {
return this.recentSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]);
@@ -119,6 +120,9 @@ export default {
showDefaultSuggestions() {
return this.availableDefaultSuggestions.length > 0;
},
+ showNoMatchesText() {
+ return this.searchKey && !this.availableSuggestions.length;
+ },
showRecentSuggestions() {
return (
this.isRecentSuggestionsEnabled && this.recentSuggestions.length > 0 && !this.searchKey
@@ -163,11 +167,20 @@ export default {
this.searchKey = data;
if (!this.suggestionsLoading && !this.activeTokenValue) {
- const search = this.searchTerm ? this.searchTerm : data;
+ let search = this.searchTerm ? this.searchTerm : data;
+
+ if (search.startsWith('"') && search.endsWith('"')) {
+ search = stripQuotes(search);
+ } else if (search.startsWith('"')) {
+ search = search.slice(1, search.length);
+ }
+
this.$emit('fetch-suggestions', search);
}
}, DEBOUNCE_DELAY),
- handleTokenValueSelected(activeTokenValue) {
+ handleTokenValueSelected(selectedValue) {
+ const activeTokenValue = this.getActiveTokenValue(this.suggestions, selectedValue);
+
// Make sure that;
// 1. Recently used values feature is enabled
// 2. User has actually selected a value
@@ -177,7 +190,7 @@ export default {
activeTokenValue &&
!this.preloadedTokenIds.includes(activeTokenValue[this.valueIdentifier])
) {
- setTokenValueToRecentlyUsed(this.recentSuggestionsStorageKey, activeTokenValue);
+ setTokenValueToRecentlyUsed(this.config.recentSuggestionsStorageKey, activeTokenValue);
}
},
},
@@ -192,7 +205,7 @@ export default {
v-bind="$attrs"
v-on="$listeners"
@input="handleInput"
- @select="handleTokenValueSelected(activeTokenValue)"
+ @select="handleTokenValueSelected"
>
<template #view-token="viewTokenProps">
<slot name="view-token" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot>
@@ -222,6 +235,9 @@ export default {
:suggestions="preloadedSuggestions"
></slot>
<gl-loading-icon v-if="suggestionsLoading" size="sm" />
+ <gl-dropdown-text v-else-if="showNoMatchesText">
+ {{ __('No matches found') }}
+ </gl-dropdown-text>
<template v-else>
<slot name="suggestions-list" :suggestions="availableSuggestions"></slot>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
deleted file mode 100644
index 9c2f5306654..00000000000
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
+++ /dev/null
@@ -1,129 +0,0 @@
-<script>
-import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import createFlash from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { __ } from '~/locale';
-import { DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants';
-import searchEpicsQuery from '../queries/search_epics.query.graphql';
-
-import BaseToken from './base_token.vue';
-
-export default {
- prefix: '&',
- separator: '::',
- components: {
- BaseToken,
- GlFilteredSearchSuggestion,
- },
- props: {
- config: {
- type: Object,
- required: true,
- },
- value: {
- type: Object,
- required: true,
- },
- active: {
- type: Boolean,
- required: true,
- },
- },
- data() {
- return {
- epics: this.config.initialEpics || [],
- loading: false,
- };
- },
- computed: {
- idProperty() {
- return this.config.idProperty || 'iid';
- },
- currentValue() {
- const epicIid = Number(this.value.data);
- if (epicIid) {
- return epicIid;
- }
- return this.value.data;
- },
- defaultEpics() {
- return this.config.defaultEpics || DEFAULT_NONE_ANY;
- },
- availableDefaultEpics() {
- if (this.value.operator === OPERATOR_IS_NOT) {
- return this.defaultEpics.filter(
- (suggestion) => !FILTER_NONE_ANY.includes(suggestion.value),
- );
- }
- return this.defaultEpics;
- },
- },
- methods: {
- fetchEpics(search = '') {
- return this.$apollo
- .query({
- query: searchEpicsQuery,
- variables: { fullPath: this.config.fullPath, search },
- })
- .then(({ data }) => data.group?.epics.nodes);
- },
- fetchEpicsBySearchTerm(search) {
- this.loading = true;
- this.fetchEpics(search)
- .then((response) => {
- this.epics = Array.isArray(response) ? response : response?.data;
- })
- .catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
- .finally(() => {
- this.loading = false;
- });
- },
- getActiveEpic(epics, data) {
- if (data && epics.length) {
- return epics.find((epic) => this.getValue(epic) === data);
- }
- return undefined;
- },
- getValue(epic) {
- return this.getEpicIdProperty(epic).toString();
- },
- displayValue(epic) {
- return `${this.$options.prefix}${this.getEpicIdProperty(epic)}${this.$options.separator}${
- epic?.title
- }`;
- },
- getEpicIdProperty(epic) {
- return getIdFromGraphQLId(epic[this.idProperty]);
- },
- },
-};
-</script>
-
-<template>
- <base-token
- :config="config"
- :value="value"
- :active="active"
- :suggestions-loading="loading"
- :suggestions="epics"
- :get-active-token-value="getActiveEpic"
- :default-suggestions="availableDefaultEpics"
- :recent-suggestions-storage-key="config.recentSuggestionsStorageKey"
- search-by="title"
- @fetch-suggestions="fetchEpicsBySearchTerm"
- v-on="$listeners"
- >
- <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
- {{ activeTokenValue ? displayValue(activeTokenValue) : inputValue }}
- </template>
- <template #suggestions-list="{ suggestions }">
- <gl-filtered-search-suggestion
- v-for="epic in suggestions"
- :key="epic.id"
- :value="getValue(epic)"
- >
- {{ epic.title }}
- </gl-filtered-search-suggestion>
- </template>
- </base-token>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
deleted file mode 100644
index aff93ebc9c0..00000000000
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
+++ /dev/null
@@ -1,138 +0,0 @@
-<script>
-import { GlDropdownDivider, GlDropdownSectionHeader, GlFilteredSearchSuggestion } from '@gitlab/ui';
-import createFlash from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { __ } from '~/locale';
-import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
-import { formatDate } from '~/lib/utils/datetime_utility';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { DEFAULT_ITERATIONS } from '../constants';
-
-export default {
- components: {
- BaseToken,
- GlDropdownDivider,
- GlDropdownSectionHeader,
- GlFilteredSearchSuggestion,
- },
- mixins: [glFeatureFlagMixin()],
- props: {
- active: {
- type: Boolean,
- required: true,
- },
- config: {
- type: Object,
- required: true,
- },
- value: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- iterations: this.config.initialIterations || [],
- loading: false,
- };
- },
- computed: {
- defaultIterations() {
- return this.config.defaultIterations || DEFAULT_ITERATIONS;
- },
- },
- methods: {
- getActiveIteration(iterations, data) {
- return iterations.find((iteration) => this.getValue(iteration) === data);
- },
- groupIterationsByCadence(iterations) {
- const cadences = [];
- iterations.forEach((iteration) => {
- if (!iteration.iterationCadence) {
- return;
- }
- const { title } = iteration.iterationCadence;
- const cadenceIteration = {
- id: iteration.id,
- title: iteration.title,
- period: this.getIterationPeriod(iteration),
- };
- const cadence = cadences.find((cad) => cad.title === title);
- if (cadence) {
- cadence.iterations.push(cadenceIteration);
- } else {
- cadences.push({ title, iterations: [cadenceIteration] });
- }
- });
- return cadences;
- },
- fetchIterations(searchTerm) {
- this.loading = true;
- this.config
- .fetchIterations(searchTerm)
- .then((response) => {
- this.iterations = Array.isArray(response) ? response : response.data;
- })
- .catch(() => {
- createFlash({ message: __('There was a problem fetching iterations.') });
- })
- .finally(() => {
- this.loading = false;
- });
- },
- getValue(iteration) {
- return String(getIdFromGraphQLId(iteration.id));
- },
- /**
- * TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/344619
- * This method also exists as a utility function in ee/../iterations/utils.js
- * Remove the duplication when iteration token is moved to EE.
- */
- getIterationPeriod({ startDate, dueDate }) {
- const start = formatDate(startDate, 'mmm d, yyyy', true);
- const due = formatDate(dueDate, 'mmm d, yyyy', true);
- return `${start} - ${due}`;
- },
- },
-};
-</script>
-
-<template>
- <base-token
- :active="active"
- :config="config"
- :value="value"
- :default-suggestions="defaultIterations"
- :suggestions="iterations"
- :suggestions-loading="loading"
- :get-active-token-value="getActiveIteration"
- @fetch-suggestions="fetchIterations"
- v-on="$listeners"
- >
- <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
- {{ activeTokenValue ? activeTokenValue.title : inputValue }}
- </template>
- <template #suggestions-list="{ suggestions }">
- <template v-for="(cadence, index) in groupIterationsByCadence(suggestions)">
- <gl-dropdown-divider v-if="index !== 0" :key="index" />
- <gl-dropdown-section-header
- :key="cadence.title"
- class="gl-overflow-hidden"
- :title="cadence.title"
- >
- {{ cadence.title }}
- </gl-dropdown-section-header>
- <gl-filtered-search-suggestion
- v-for="iteration in cadence.iterations"
- :key="iteration.id"
- :value="getValue(iteration)"
- >
- {{ iteration.title }}
- <div v-if="glFeatures.iterationCadences" class="gl-text-gray-400">
- {{ iteration.period }}
- </div>
- </gl-filtered-search-suggestion>
- </template>
- </template>
- </base-token>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
index c31f3a25fb1..3f7a8920f48 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -104,7 +104,6 @@ export default {
:suggestions="labels"
:get-active-token-value="getActiveLabel"
:default-suggestions="defaultLabels"
- :recent-suggestions-storage-key="config.recentSuggestionsStorageKey"
@fetch-suggestions="fetchLabels"
v-on="$listeners"
>
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 523438f459c..0d3394788fa 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
@@ -2,7 +2,7 @@
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
-import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
+import { sortMilestonesByDueDate } from '~/milestones/utils';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { DEFAULT_MILESTONES } from '../constants';
import { stripQuotes } from '../filtered_search_utils';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue
deleted file mode 100644
index 280fb234576..00000000000
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<script>
-import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
-import { DEFAULT_NONE_ANY, WEIGHT_TOKEN_SUGGESTIONS_SIZE } from '../constants';
-
-const weights = Array.from(Array(WEIGHT_TOKEN_SUGGESTIONS_SIZE), (_, index) => index.toString());
-
-export default {
- components: {
- BaseToken,
- GlFilteredSearchSuggestion,
- },
- props: {
- active: {
- type: Boolean,
- required: true,
- },
- config: {
- type: Object,
- required: true,
- },
- value: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- weights,
- };
- },
- computed: {
- defaultWeights() {
- return this.config.defaultWeights || DEFAULT_NONE_ANY;
- },
- },
- methods: {
- getActiveWeight(weightSuggestions, data) {
- return weightSuggestions.find((weight) => weight === data);
- },
- updateWeights(searchTerm) {
- const weight = parseInt(searchTerm, 10);
- this.weights = Number.isNaN(weight) ? weights : [String(weight)];
- },
- },
-};
-</script>
-
-<template>
- <base-token
- :active="active"
- :config="config"
- :value="value"
- :default-suggestions="defaultWeights"
- :suggestions="weights"
- :get-active-token-value="getActiveWeight"
- @fetch-suggestions="updateWeights"
- v-on="$listeners"
- >
- <template #suggestions-list="{ suggestions }">
- <gl-filtered-search-suggestion v-for="weight of suggestions" :key="weight" :value="weight">
- {{ weight }}
- </gl-filtered-search-suggestion>
- </template>
- </base-token>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
new file mode 100644
index 00000000000..cdd7a074f34
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
@@ -0,0 +1,27 @@
+import InputCopyToggleVisibility from './input_copy_toggle_visibility.vue';
+
+export default {
+ component: InputCopyToggleVisibility,
+ title: 'vue_shared/components/form/input_copy_toggle_visibility',
+};
+
+const defaultProps = {
+ value: 'hR8x1fuJbzwu5uFKLf9e',
+ formInputGroupProps: { class: 'gl-form-input-xl' },
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { InputCopyToggleVisibility },
+ props: Object.keys(argTypes),
+ template: `<input-copy-toggle-visibility
+ :value="value"
+ :initial-visibility="initialVisibility"
+ :show-toggle-visibility-button="showToggleVisibilityButton"
+ :show-copy-button="showCopyButton"
+ :form-input-group-props="formInputGroupProps"
+ :copy-button-title="copyButtonTitle"
+ />`,
+});
+
+export const Default = Template.bind({});
+Default.args = defaultProps;
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
new file mode 100644
index 00000000000..06949b59823
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
@@ -0,0 +1,127 @@
+<script>
+import { GlFormInputGroup, GlFormGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
+
+import { __ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+export default {
+ name: 'InputCopyToggleVisibility',
+ i18n: {
+ toggleVisibilityLabelHide: __('Click to hide'),
+ toggleVisibilityLabelReveal: __('Click to reveal'),
+ },
+ components: {
+ GlFormInputGroup,
+ GlFormGroup,
+ GlButton,
+ ClipboardButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialVisibility: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showToggleVisibilityButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ showCopyButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ copyButtonTitle: {
+ type: String,
+ required: false,
+ default: __('Copy'),
+ },
+ formInputGroupProps: {
+ type: Object,
+ required: false,
+ default() {
+ return {};
+ },
+ },
+ },
+ data() {
+ return {
+ valueIsVisible: this.initialVisibility,
+ };
+ },
+ computed: {
+ toggleVisibilityLabel() {
+ return this.valueIsVisible
+ ? this.$options.i18n.toggleVisibilityLabelHide
+ : this.$options.i18n.toggleVisibilityLabelReveal;
+ },
+ toggleVisibilityIcon() {
+ return this.valueIsVisible ? 'eye-slash' : 'eye';
+ },
+ computedValueIsVisible() {
+ return !this.showToggleVisibilityButton || this.valueIsVisible;
+ },
+ displayedValue() {
+ return this.computedValueIsVisible ? this.value : '*'.repeat(this.value.length || 20);
+ },
+ },
+ methods: {
+ handleToggleVisibilityButtonClick() {
+ this.valueIsVisible = !this.valueIsVisible;
+
+ this.$emit('visibility-change', this.valueIsVisible);
+ },
+ handleCopyButtonClick() {
+ this.$emit('copy');
+ },
+ handleFormInputCopy(event) {
+ if (this.computedValueIsVisible) {
+ return;
+ }
+
+ event.clipboardData.setData('text/plain', this.value);
+ event.preventDefault();
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-group v-bind="$attrs">
+ <gl-form-input-group
+ :value="displayedValue"
+ input-class="gl-font-monospace! gl-cursor-default!"
+ select-on-click
+ readonly
+ v-bind="formInputGroupProps"
+ @copy="handleFormInputCopy"
+ >
+ <template v-if="showToggleVisibilityButton || showCopyButton" #append>
+ <gl-button
+ v-if="showToggleVisibilityButton"
+ v-gl-tooltip.hover="toggleVisibilityLabel"
+ :aria-label="toggleVisibilityLabel"
+ :icon="toggleVisibilityIcon"
+ @click="handleToggleVisibilityButtonClick"
+ />
+ <clipboard-button
+ v-if="showCopyButton"
+ :text="value"
+ :title="copyButtonTitle"
+ @click="handleCopyButtonClick"
+ />
+ </template>
+ </gl-form-input-group>
+ <template v-for="slot in Object.keys($slots)" #[slot]>
+ <slot :name="slot"></slot>
+ </template>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index 6ace0bd88f8..9bff469b670 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -5,6 +5,7 @@ import {
GlSafeHtmlDirective,
GlAvatarLink,
GlAvatarLabeled,
+ GlTooltip,
} from '@gitlab/ui';
import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { glEmojiTag } from '../../emoji';
@@ -26,6 +27,7 @@ export default {
GlButton,
GlAvatarLink,
GlAvatarLabeled,
+ GlTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
diff --git a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js b/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js
deleted file mode 100644
index 28aa93d6680..00000000000
--- a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import IssuableHeaderWarnings from './issuable_header_warnings.vue';
-
-export default function issuableHeaderWarnings(store) {
- const el = document.getElementById('js-issuable-header-warnings');
-
- if (!el) {
- return false;
- }
-
- const { hidden } = el.dataset;
-
- return new Vue({
- el,
- store,
- provide: { hidden: parseBoolean(hidden) },
- render(createElement) {
- return createElement(IssuableHeaderWarnings);
- },
- });
-}
diff --git a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue b/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue
deleted file mode 100644
index 82223ab9ef4..00000000000
--- a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
-import { __ } from '~/locale';
-
-export default {
- components: {
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- inject: ['hidden'],
- computed: {
- ...mapGetters(['getNoteableData']),
- isLocked() {
- return this.getNoteableData.discussion_locked;
- },
- isConfidential() {
- return this.getNoteableData.confidential;
- },
- warningIconsMeta() {
- return [
- {
- iconName: 'lock',
- visible: this.isLocked,
- dataTestId: 'locked',
- },
- {
- iconName: 'eye-slash',
- visible: this.isConfidential,
- dataTestId: 'confidential',
- },
- {
- iconName: 'spam',
- visible: this.hidden,
- dataTestId: 'hidden',
- tooltip: __('This issue is hidden because its author has been banned'),
- },
- ];
- },
- },
-};
-</script>
-
-<template>
- <div class="gl-display-inline-block">
- <template v-for="meta in warningIconsMeta">
- <div
- v-if="meta.visible"
- :key="meta.iconName"
- v-gl-tooltip
- :data-testid="meta.dataTestId"
- :title="meta.tooltip || null"
- class="issuable-warning-icon inline"
- >
- <gl-icon :name="meta.iconName" class="icon" />
- </div>
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
deleted file mode 100644
index 5955f31fc70..00000000000
--- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
+++ /dev/null
@@ -1,107 +0,0 @@
-<script>
-import { GlTooltipDirective } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
-import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-
-export default {
- components: {
- UserAvatarLink,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- assignees: {
- type: Array,
- required: true,
- },
- iconSize: {
- type: Number,
- required: false,
- default: 24,
- },
- imgCssClasses: {
- type: String,
- required: false,
- default: '',
- },
- maxVisible: {
- type: Number,
- required: false,
- default: 3,
- },
- },
- data() {
- return {
- maxAssignees: 99,
- };
- },
- computed: {
- assigneesToShow() {
- const numShownAssignees = this.assignees.length - this.numHiddenAssignees;
- return this.assignees.slice(0, numShownAssignees);
- },
- assigneesCounterTooltip() {
- return sprintf(__('%{count} more assignees'), { count: this.numHiddenAssignees });
- },
- numHiddenAssignees() {
- if (this.assignees.length > this.maxVisible) {
- return this.assignees.length - this.maxVisible + 1;
- }
- return 0;
- },
- assigneeCounterLabel() {
- if (this.numHiddenAssignees > this.maxAssignees) {
- return `${this.maxAssignees}+`;
- }
-
- return `+${this.numHiddenAssignees}`;
- },
- },
- methods: {
- avatarUrlTitle(assignee) {
- return sprintf(__('Assigned to %{assigneeName}'), {
- assigneeName: assignee.name,
- });
- },
- // This method is for backward compat
- // since Graph query would return camelCase
- // props while Rails would return snake_case
- webUrl(assignee) {
- return assignee.web_url || assignee.webUrl;
- },
- avatarUrl(assignee) {
- return assignee.avatar_url || assignee.avatarUrl;
- },
- },
-};
-</script>
-<template>
- <div>
- <user-avatar-link
- v-for="assignee in assigneesToShow"
- :key="assignee.id"
- :link-href="webUrl(assignee)"
- :img-alt="avatarUrlTitle(assignee)"
- :img-css-classes="imgCssClasses"
- :img-src="avatarUrl(assignee)"
- :img-size="iconSize"
- class="js-no-trigger author-link"
- tooltip-placement="bottom"
- data-qa-selector="assignee_link"
- >
- <span class="js-assignee-tooltip">
- <span class="bold d-block">{{ __('Assignee') }}</span> {{ assignee.name }}
- <span v-if="assignee.username" class="text-white-50">@{{ assignee.username }}</span>
- </span>
- </user-avatar-link>
- <span
- v-if="numHiddenAssignees > 0"
- v-gl-tooltip.bottom
- :title="assigneesCounterTooltip"
- class="avatar-counter"
- data-qa-selector="avatar_counter_content"
- >{{ assigneeCounterLabel }}</span
- >
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
deleted file mode 100644
index 6a0c21602bd..00000000000
--- a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
+++ /dev/null
@@ -1,91 +0,0 @@
-<script>
-import { GlTooltip, GlIcon } from '@gitlab/ui';
-import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility';
-import { __, sprintf } from '~/locale';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
-
-export default {
- components: {
- GlIcon,
- GlTooltip,
- },
- mixins: [timeagoMixin],
- props: {
- milestone: {
- type: Object,
- required: true,
- },
- },
- computed: {
- milestoneDue() {
- const dueDate = this.milestone.due_date || this.milestone.dueDate;
-
- return dueDate ? parsePikadayDate(dueDate) : null;
- },
- milestoneStart() {
- const startDate = this.milestone.start_date || this.milestone.startDate;
-
- return startDate ? parsePikadayDate(startDate) : null;
- },
- isMilestoneStarted() {
- if (!this.milestoneStart) {
- return false;
- }
- return Date.now() > this.milestoneStart;
- },
- isMilestonePastDue() {
- if (!this.milestoneDue) {
- return false;
- }
- return Date.now() > this.milestoneDue;
- },
- milestoneDatesAbsolute() {
- if (this.milestoneDue) {
- return `(${dateInWords(this.milestoneDue)})`;
- } else if (this.milestoneStart) {
- return `(${dateInWords(this.milestoneStart)})`;
- }
- return '';
- },
- milestoneDatesHuman() {
- if (this.milestoneStart || this.milestoneDue) {
- if (this.milestoneDue) {
- return timeFor(
- this.milestoneDue,
- sprintf(__('Expired %{expiredOn}'), {
- expiredOn: this.timeFormatted(this.milestoneDue),
- }),
- );
- }
-
- return sprintf(
- this.isMilestoneStarted ? __('Started %{startsIn}') : __('Starts %{startsIn}'),
- {
- startsIn: this.timeFormatted(this.milestoneStart),
- },
- );
- }
- return '';
- },
- },
-};
-</script>
-<template>
- <div ref="milestoneDetails" class="issue-milestone-details">
- <gl-icon :size="16" class="gl-mr-2" name="clock" />
- <span class="milestone-title d-inline-block">{{ milestone.title }}</span>
- <gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone">
- <span class="bold">{{ __('Milestone') }}</span> <br />
- <span>{{ milestone.title }}</span> <br />
- <span
- v-if="milestoneStart || milestoneDue"
- :class="{
- 'text-danger-muted': isMilestonePastDue,
- 'text-tertiary': !isMilestonePastDue,
- }"
- ><span>{{ milestoneDatesHuman }}</span
- ><br /><span>{{ milestoneDatesAbsolute }}</span>
- </span>
- </gl-tooltip>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
deleted file mode 100644
index 8aeff9257a5..00000000000
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ /dev/null
@@ -1,201 +0,0 @@
-<script>
-import '~/commons/bootstrap';
-import {
- GlIcon,
- GlTooltip,
- GlTooltipDirective,
- GlButton,
- GlSafeHtmlDirective as SafeHtml,
-} from '@gitlab/ui';
-import IssueDueDate from '~/boards/components/issue_due_date.vue';
-import { sprintf } from '~/locale';
-import relatedIssuableMixin from '../../mixins/related_issuable_mixin';
-import CiIcon from '../ci_icon.vue';
-import IssueAssignees from './issue_assignees.vue';
-import IssueMilestone from './issue_milestone.vue';
-
-export default {
- name: 'IssueItem',
- components: {
- IssueMilestone,
- IssueAssignees,
- CiIcon,
- GlIcon,
- GlTooltip,
- IssueWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
- IssueDueDate,
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- SafeHtml,
- },
- mixins: [relatedIssuableMixin],
- props: {
- canReorder: {
- type: Boolean,
- required: false,
- default: false,
- },
- isLocked: {
- type: Boolean,
- required: false,
- default: false,
- },
- lockedMessage: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- stateTitle() {
- return sprintf(
- '<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>',
- {
- state: this.stateText,
- timeInWords: this.stateTimeInWords,
- timestamp: this.stateTimestamp,
- },
- );
- },
- iconClasses() {
- return `${this.iconClass} ic-${this.iconName}`;
- },
- },
-};
-</script>
-
-<template>
- <div
- :class="{
- 'issuable-info-container': !canReorder,
- 'card-body': canReorder,
- }"
- class="item-body d-flex align-items-center py-2 px-3"
- >
- <div
- class="item-contents gl-display-flex gl-align-items-center gl-flex-wrap gl-flex-grow-1 flex-xl-nowrap gl-min-h-7"
- >
- <!-- Title area: Status icon (XL) and title -->
- <div class="item-title d-flex align-items-xl-center mb-xl-0 gl-min-w-0">
- <div ref="iconElementXL">
- <gl-icon
- v-if="hasState"
- ref="iconElementXL"
- class="mr-2 d-block"
- :class="iconClasses"
- :name="iconName"
- :title="stateTitle"
- :aria-label="state"
- />
- </div>
- <gl-tooltip :target="() => $refs.iconElementXL">
- <span v-safe-html="stateTitle"></span>
- </gl-tooltip>
- <gl-icon
- v-if="confidential"
- v-gl-tooltip
- name="eye-slash"
- :title="__('Confidential')"
- class="confidential-icon gl-mr-2 align-self-baseline align-self-md-auto mt-xl-0"
- :aria-label="__('Confidential')"
- />
- <a :href="computedPath" class="sortable-link gl-font-weight-normal">{{ title }}</a>
- </div>
-
- <!-- Info area: meta, path, and assignees -->
- <div class="item-info-area d-flex flex-xl-grow-1 flex-shrink-0">
- <!-- Meta area: path and attributes -->
- <!-- If there is no room beside the path, meta attributes are put ABOVE it (flex-wrap-reverse). -->
- <!-- See design: https://gitlab-org.gitlab.io/gitlab-design/hosted/pedro/%2383-issue-mr-rows-cards-spec-previews/#artboard16 -->
- <div
- class="item-meta d-flex flex-wrap-reverse justify-content-start justify-content-md-between"
- >
- <!-- Path area: status icon (<XL), path, issue # -->
- <div
- class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2"
- >
- <gl-tooltip :target="() => this.$refs.iconElement">
- <span v-safe-html="stateTitle"></span>
- </gl-tooltip>
- <span v-gl-tooltip :title="itemPath" class="path-id-text d-inline-block">{{
- itemPath
- }}</span>
- <span>{{ pathIdSeparator }}{{ itemId }}</span>
- </div>
-
- <!-- Attributes area: CI, epic count, weight, milestone -->
- <!-- They have a different order on large screen sizes -->
- <div class="item-attributes-area d-flex align-items-center mt-2 mt-xl-0">
- <span v-if="hasPipeline" class="mr-ci-status order-md-last">
- <a :href="pipelineStatus.details_path">
- <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" />
- </a>
- </span>
-
- <issue-milestone
- v-if="hasMilestone"
- :milestone="milestone"
- class="d-flex align-items-center item-milestone order-md-first ml-md-0"
- />
-
- <!-- Flex order for slots is defined in the parent component: e.g. related_issues_block.vue -->
- <span v-if="weight > 0" class="order-md-1">
- <issue-weight
- :weight="weight"
- class="item-weight gl-display-flex gl-align-items-center"
- tag-name="span"
- />
- </span>
-
- <span v-if="dueDate" class="order-md-1">
- <issue-due-date
- :date="dueDate"
- :closed="Boolean(closedAt)"
- tooltip-placement="top"
- css-class="item-due-date gl-display-flex gl-align-items-center"
- />
- </span>
-
- <issue-assignees
- v-if="hasAssignees"
- :assignees="assignees"
- class="item-assignees align-items-center align-self-end flex-shrink-0 order-md-2 d-none d-md-flex"
- />
- </div>
- </div>
-
- <!-- Assignees. On small layouts, these are put here, at the end of the card. -->
- <issue-assignees
- v-if="assignees.length !== 0"
- :assignees="assignees"
- class="item-assignees d-flex align-items-center align-self-end flex-shrink-0 d-md-none ml-2"
- />
- </div>
- </div>
-
- <span
- v-if="isLocked"
- ref="lockIcon"
- v-gl-tooltip
- class="gl-px-3 gl-display-inline-block gl-cursor-not-allowed"
- :title="lockedMessage"
- >
- <gl-icon name="lock" />
- </span>
- <gl-button
- v-else-if="canRemove"
- ref="removeButton"
- v-gl-tooltip
- icon="close"
- category="tertiary"
- :disabled="removeDisabled"
- class="js-issue-item-remove-button gl-ml-3"
- data-qa-selector="remove_related_issue_button"
- :title="__('Remove')"
- :aria-label="__('Remove')"
- @click="onRemoveRequest"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/line_numbers.vue b/app/assets/javascripts/vue_shared/components/line_numbers.vue
new file mode 100644
index 00000000000..7e17cca3dcc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/line_numbers.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlIcon, GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ },
+ props: {
+ lines: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ currentlyHighlightedLine: null,
+ };
+ },
+ mounted() {
+ this.scrollToLine();
+ },
+ methods: {
+ scrollToLine(hash = window.location.hash) {
+ const lineToHighlight = hash && this.$el.querySelector(hash);
+
+ if (!lineToHighlight) {
+ return;
+ }
+
+ if (this.currentlyHighlightedLine) {
+ this.currentlyHighlightedLine.classList.remove('hll');
+ }
+
+ lineToHighlight.classList.add('hll');
+ this.currentlyHighlightedLine = lineToHighlight;
+ lineToHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ },
+ },
+};
+</script>
+<template>
+ <div class="line-numbers">
+ <gl-link
+ v-for="line in lines"
+ :id="`L${line}`"
+ :key="line"
+ class="diff-line-num"
+ :href="`#L${line}`"
+ :data-line-number="line"
+ @click="scrollToLine(`#L${line}`)"
+ >
+ <gl-icon :size="12" name="link" />
+ {{ line }}
+ </gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index e36cfb3b275..2f6776f835e 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -165,6 +165,6 @@ export default {
<template>
<div>
<div class="flash-container js-suggestions-flash"></div>
- <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md"></div>
+ <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md suggestions"></div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 912aa8ce294..f1c293c87f4 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,18 +1,13 @@
<script>
import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
-import { isExperimentVariant } from '~/experimentation/utils';
-import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
-import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
export default {
- inviteMembersInComment: INVITE_MEMBERS_IN_COMMENT,
components: {
GlButton,
GlLink,
GlLoadingIcon,
GlSprintf,
GlIcon,
- InviteMembersTrigger,
},
props: {
markdownDocsPath: {
@@ -34,9 +29,6 @@ export default {
hasQuickActionsDocsPath() {
return this.quickActionsDocsPath !== '';
},
- inviteCommentEnabled() {
- return isExperimentVariant(INVITE_MEMBERS_IN_COMMENT, 'invite_member_link');
- },
},
};
</script>
@@ -67,16 +59,6 @@ export default {
</template>
</div>
<span v-if="canAttachFile" class="uploading-container">
- <invite-members-trigger
- v-if="inviteCommentEnabled"
- classes="gl-mr-3 gl-vertical-align-text-bottom"
- :display-text="s__('InviteMember|Invite Member')"
- icon="assignee"
- variant="link"
- :track-experiment="$options.inviteMembersInComment"
- :trigger-source="$options.inviteMembersInComment"
- data-track-action="comment_invite_click"
- />
<span class="uploading-progress-container hide">
<gl-icon name="media" />
<span class="attaching-file-message"></span>
diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
new file mode 100644
index 00000000000..7d2af7983d1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
@@ -0,0 +1,93 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export const i18n = {
+ DEFAULT_TEXT: __('Select a new namespace'),
+ GROUPS: __('Groups'),
+ USERS: __('Users'),
+};
+
+const filterByName = (data, searchTerm = '') =>
+ data.filter((d) => d.humanName.toLowerCase().includes(searchTerm));
+
+export default {
+ name: 'NamespaceSelect',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ },
+ props: {
+ data: {
+ type: Object,
+ required: true,
+ },
+ fullWidth: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ searchTerm: '',
+ selectedNamespace: null,
+ };
+ },
+ computed: {
+ hasUserNamespaces() {
+ return this.data.user?.length;
+ },
+ hasGroupNamespaces() {
+ return this.data.group?.length;
+ },
+ filteredGroupNamespaces() {
+ if (!this.hasGroupNamespaces) return [];
+ return filterByName(this.data.group, this.searchTerm);
+ },
+ filteredUserNamespaces() {
+ if (!this.hasUserNamespaces) return [];
+ return filterByName(this.data.user, this.searchTerm);
+ },
+ selectedNamespaceText() {
+ return this.selectedNamespace?.humanName || this.$options.i18n.DEFAULT_TEXT;
+ },
+ },
+ methods: {
+ handleSelect(item) {
+ this.selectedNamespace = item;
+ this.$emit('select', item);
+ },
+ },
+ i18n,
+};
+</script>
+<template>
+ <gl-dropdown :text="selectedNamespaceText" :block="fullWidth">
+ <template #header>
+ <gl-search-box-by-type v-model.trim="searchTerm" />
+ </template>
+ <div v-if="hasGroupNamespaces" class="qa-namespaces-list-groups">
+ <gl-dropdown-section-header>{{ $options.i18n.GROUPS }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="item in filteredGroupNamespaces"
+ :key="item.id"
+ class="qa-namespaces-list-item"
+ @click="handleSelect(item)"
+ >{{ item.humanName }}</gl-dropdown-item
+ >
+ </div>
+ <div v-if="hasUserNamespaces" class="qa-namespaces-list-users">
+ <gl-dropdown-section-header>{{ $options.i18n.USERS }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="item in filteredUserNamespaces"
+ :key="item.id"
+ class="qa-namespaces-list-item"
+ @click="handleSelect(item)"
+ >{{ item.humanName }}</gl-dropdown-item
+ >
+ </div>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index 9ea14ed506c..624dbcc6d8e 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -39,6 +39,11 @@ export default {
required: false,
default: null,
},
+ isOverviewTab: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
...mapGetters(['getUserData']),
@@ -46,9 +51,10 @@ export default {
return renderMarkdown(this.note.body);
},
avatarSize() {
- if (this.line) {
- return 16;
+ if (this.line && !this.isOverviewTab) {
+ return 24;
}
+
return 40;
},
},
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 8877cfa39fb..1963d1aa7fe 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -141,6 +141,7 @@ export default {
variant="link"
:icon="descriptionVersionToggleIcon"
data-testid="compare-btn"
+ class="gl-vertical-align-text-bottom"
@click="toggleDescriptionVersion"
>{{ __('Compare with previous version') }}</gl-button
>
@@ -149,6 +150,7 @@ export default {
:icon="showLines ? 'chevron-up' : 'chevron-down'"
variant="link"
data-testid="outdated-lines-change-btn"
+ class="gl-vertical-align-text-bottom"
@click="toggleDiff"
>
{{ __('Compare changes') }}
diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
new file mode 100644
index 00000000000..e31446f4bb8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
@@ -0,0 +1,40 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import PaginationBar from './pagination_bar.vue';
+
+export default {
+ component: PaginationBar,
+ title: 'vue_shared/components/pagination_bar/pagination_bar',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { PaginationBar },
+ props: Object.keys(argTypes),
+ template: `<pagination-bar v-bind="$props" v-on="{ 'set-page-size': setPageSize, 'set-page': setPage }" />`,
+});
+
+export const Default = Template.bind({});
+
+Default.args = {
+ pageInfo: {
+ perPage: 20,
+ page: 2,
+ total: 83,
+ totalPages: 5,
+ },
+ pageSizes: [20, 50, 100],
+};
+
+Default.argTypes = {
+ pageInfo: {
+ description: 'Page info object',
+ control: { type: 'object' },
+ },
+ pageSizes: {
+ description: 'Array of possible page sizes',
+ control: { type: 'array' },
+ },
+
+ // events
+ setPageSize: { action: 'set-page-size' },
+ setPage: { action: 'set-page' },
+};
diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue
new file mode 100644
index 00000000000..b4d565991f5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
+
+const DEFAULT_PAGE_SIZES = [20, 50, 100];
+
+export default {
+ components: {
+ PaginationLinks,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlSprintf,
+ },
+ props: {
+ pageInfo: {
+ required: true,
+ type: Object,
+ },
+ pageSizes: {
+ required: false,
+ type: Array,
+ default: () => DEFAULT_PAGE_SIZES,
+ },
+ },
+
+ computed: {
+ humanizedTotal() {
+ return this.pageInfo.total >= 1000 ? __('1000+') : this.pageInfo.total;
+ },
+
+ paginationInfo() {
+ const { page, perPage, totalPages, total } = this.pageInfo;
+ const itemsCount = page === totalPages ? total - (page - 1) * perPage : perPage;
+ const start = (page - 1) * perPage + 1;
+ const end = start + itemsCount - 1;
+
+ return { start, end };
+ },
+ },
+
+ methods: {
+ setPage(page) {
+ // eslint-disable-next-line spaced-comment
+ /**
+ * Emitted when selected page is updated
+ *
+ * @event set-page
+ **/
+ this.$emit('set-page', page);
+ },
+
+ setPageSize(pageSize) {
+ // eslint-disable-next-line spaced-comment
+ /**
+ * Emitted when page size is updated
+ *
+ * @event set-page-size
+ **/
+ this.$emit('set-page-size', pageSize);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center">
+ <pagination-links :change="setPage" :page-info="pageInfo" class="gl-m-0" />
+ <gl-dropdown category="tertiary" class="gl-ml-auto" data-testid="page-size">
+ <template #button-content>
+ <span class="gl-font-weight-bold">
+ <gl-sprintf :message="__('%{count} items per page')">
+ <template #count>
+ {{ pageInfo.perPage }}
+ </template>
+ </gl-sprintf>
+ </span>
+ <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" />
+ </template>
+ <gl-dropdown-item v-for="size in pageSizes" :key="size" @click="setPageSize(size)">
+ <gl-sprintf :message="__('%{count} items per page')">
+ <template #count>
+ {{ size }}
+ </template>
+ </gl-sprintf>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <div class="gl-ml-2" data-testid="information">
+ <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')">
+ <template #start>
+ {{ paginationInfo.start }}
+ </template>
+ <template #end>
+ {{ paginationInfo.end }}
+ </template>
+ <template #total>
+ {{ humanizedTotal }}
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
index 933a215112b..6bb321713d5 100644
--- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
@@ -54,10 +54,10 @@ export default {
class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1"
:class="optionalClasses"
>
- <div class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5">
+ <div class="gl-display-flex gl-align-items-center gl-py-3">
<div
v-if="$slots['left-action']"
- class="gl-w-7 gl-display-none gl-sm-display-flex gl-justify-content-start gl-pl-2"
+ class="gl-w-7 gl-display-flex gl-justify-content-start gl-pl-2"
>
<slot name="left-action"></slot>
</div>
@@ -105,7 +105,7 @@ export default {
</div>
<div
v-if="$slots['right-action']"
- class="gl-w-9 gl-display-none gl-sm-display-flex gl-justify-content-end gl-pr-1"
+ class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1"
>
<slot name="right-action"></slot>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue b/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue
index 93396219a54..4c2816b63b2 100644
--- a/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
name: 'MetadataItem',
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
deleted file mode 100644
index a1dca65a423..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
+++ /dev/null
@@ -1,101 +0,0 @@
-<script>
-import { dateInWords, timeFor } from '~/lib/utils/datetime_utility';
-import { __ } from '~/locale';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
-import collapsedCalendarIcon from './collapsed_calendar_icon.vue';
-
-export default {
- name: 'SidebarCollapsedGroupedDatePicker',
- components: {
- collapsedCalendarIcon,
- },
- mixins: [timeagoMixin],
- props: {
- collapsed: {
- type: Boolean,
- required: false,
- default: true,
- },
- minDate: {
- type: Date,
- required: false,
- default: null,
- },
- maxDate: {
- type: Date,
- required: false,
- default: null,
- },
- disableClickableIcons: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- hasMinAndMaxDates() {
- return this.minDate && this.maxDate;
- },
- hasNoMinAndMaxDates() {
- return !this.minDate && !this.maxDate;
- },
- showMinDateBlock() {
- return this.minDate || this.hasNoMinAndMaxDates;
- },
- showFromText() {
- return !this.maxDate && this.minDate;
- },
- iconClass() {
- const disabledClass = this.disableClickableIcons ? 'disabled' : '';
- return `sidebar-collapsed-icon calendar-icon ${disabledClass}`;
- },
- },
- methods: {
- toggleSidebar() {
- this.$emit('toggleCollapse');
- },
- dateText(dateType = 'min') {
- const date = this[`${dateType}Date`];
- const dateWords = dateInWords(date, true);
- const parsedDateWords = dateWords ? dateWords.replace(',', '') : dateWords;
-
- return date ? parsedDateWords : __('None');
- },
- tooltipText(dateType = 'min') {
- const defaultText = dateType === 'min' ? __('Start date') : __('Due date');
- const date = this[`${dateType}Date`];
- const timeAgo = dateType === 'min' ? this.timeFormatted(date) : timeFor(date);
- const dateText = date ? [this.dateText(dateType), `(${timeAgo})`].join(' ') : '';
-
- if (date) {
- return [defaultText, dateText].join('<br />');
- }
- return __('Start and due date');
- },
- },
-};
-</script>
-
-<template>
- <div class="block sidebar-grouped-item gl-cursor-pointer" role="button" @click="toggleSidebar">
- <collapsed-calendar-icon
- v-if="showMinDateBlock"
- :container-class="iconClass"
- :tooltip-text="tooltipText('min')"
- >
- <span class="sidebar-collapsed-value">
- <span v-if="showFromText">{{ __('From') }}</span> <span>{{ dateText('min') }}</span>
- </span>
- </collapsed-calendar-icon>
- <div v-if="hasMinAndMaxDates" class="text-center sidebar-collapsed-divider">-</div>
- <collapsed-calendar-icon
- v-if="maxDate"
- :container-class="iconClass"
- :tooltip-text="tooltipText('max')"
- >
- <span class="sidebar-collapsed-value">
- <span v-if="!minDate">{{ __('Until') }}</span> <span>{{ dateText('max') }}</span>
- </span>
- </collapsed-calendar-icon>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index 4234bc72f3a..7e259cb8b96 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -179,6 +179,8 @@ export default {
document.addEventListener('mousedown', this.handleDocumentMousedown);
document.addEventListener('click', this.handleDocumentClick);
+
+ this.updateLabelsSetState();
},
beforeDestroy() {
document.removeEventListener('mousedown', this.handleDocumentMousedown);
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
index f7485de0342..13a6dd43207 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -172,6 +172,13 @@ export default {
showDropdown() {
this.$refs.dropdown.show();
},
+ clearSearch() {
+ if (!this.allowMultiselect || this.isStandalone) {
+ return;
+ }
+ this.searchKey = '';
+ this.setFocus();
+ },
},
};
</script>
@@ -188,12 +195,12 @@ export default {
>
<template #header>
<dropdown-header
- v-if="!isStandalone"
ref="header"
- v-model="searchKey"
+ :search-key="searchKey"
:labels-create-title="labelsCreateTitle"
:labels-list-title="labelsListTitle"
:show-dropdown-contents-create-view="showDropdownContentsCreateView"
+ :is-standalone="isStandalone"
@toggleDropdownContentsCreateView="toggleDropdownContent"
@closeDropdown="$emit('closeDropdown')"
@input="debouncedSearchKeyUpdate"
@@ -210,6 +217,7 @@ export default {
:attr-workspace-path="attrWorkspacePath"
:label-create-type="labelCreateType"
@hideCreateView="toggleDropdownContent"
+ @input="clearSearch"
/>
</template>
<template #footer>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
index 10064b01648..7a0f20b0c83 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
@@ -6,9 +6,6 @@ export default {
GlButton,
GlSearchBoxByType,
},
- model: {
- prop: 'searchKey',
- },
props: {
labelsCreateTitle: {
type: String,
@@ -31,6 +28,11 @@ export default {
type: String,
required: true,
},
+ isStandalone: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
dropdownTitle() {
@@ -47,7 +49,11 @@ export default {
<template>
<div data-testid="dropdown-header">
- <div class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!">
+ <div
+ v-if="!isStandalone"
+ class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ data-testid="dropdown-header-title"
+ >
<gl-button
v-if="showDropdownContentsCreateView"
:aria-label="__('Go back')"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
index aed5bc303ee..57ee816c4c7 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
@@ -1,10 +1,15 @@
<script>
-import { GlLabel } from '@gitlab/ui';
+import { GlIcon, GlLabel, GlTooltipDirective } from '@gitlab/ui';
import { sortBy } from 'lodash';
import { isScopedLabel } from '~/lib/utils/common_utils';
+import { s__, sprintf } from '~/locale';
export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
components: {
+ GlIcon,
GlLabel,
},
inject: ['allowScopedLabels'],
@@ -35,6 +40,23 @@ export default {
sortedSelectedLabels() {
return sortBy(this.selectedLabels, (label) => (isScopedLabel(label) ? 0 : 1));
},
+ labelsList() {
+ const labelsString = this.selectedLabels.length
+ ? this.selectedLabels
+ .slice(0, 5)
+ .map((label) => label.title)
+ .join(', ')
+ : s__('LabelSelect|Labels');
+
+ if (this.selectedLabels.length > 5) {
+ return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
+ labelsString,
+ remainingLabelCount: this.selectedLabels.length - 5,
+ });
+ }
+
+ return labelsString;
+ },
},
methods: {
labelFilterUrl(label) {
@@ -48,6 +70,9 @@ export default {
removeLabel(labelId) {
this.$emit('onLabelRemove', labelId);
},
+ handleCollapsedClick() {
+ this.$emit('onCollapsedValueClick');
+ },
},
};
</script>
@@ -57,16 +82,30 @@ export default {
:class="{
'has-labels': selectedLabels.length,
}"
- class="hide-collapsed value issuable-show-labels js-value"
+ class="value issuable-show-labels js-value"
data-testid="value-wrapper"
>
- <span v-if="!selectedLabels.length" class="text-secondary" data-testid="empty-placeholder">
+ <div
+ v-gl-tooltip.left.viewport
+ :title="labelsList"
+ class="sidebar-collapsed-icon"
+ @click="handleCollapsedClick"
+ >
+ <gl-icon name="labels" />
+ <span class="gl-font-base gl-line-height-24">{{ selectedLabels.length }}</span>
+ </div>
+ <span
+ v-if="!selectedLabels.length"
+ class="text-secondary hide-collapsed"
+ data-testid="empty-placeholder"
+ >
<slot></slot>
</span>
<template v-else>
<gl-label
v-for="label in sortedSelectedLabels"
:key="label.id"
+ class="hide-collapsed"
data-qa-selector="selected_label_content"
:data-qa-label-name="label.title"
:title="label.title"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue
deleted file mode 100644
index 122250d1ce7..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue
+++ /dev/null
@@ -1,55 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { s__, sprintf } from '~/locale';
-
-export default {
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- components: {
- GlIcon,
- },
- props: {
- labels: {
- type: Array,
- required: true,
- },
- },
- computed: {
- labelsList() {
- const labelsString = this.labels.length
- ? this.labels
- .slice(0, 5)
- .map((label) => label.title)
- .join(', ')
- : s__('LabelSelect|Labels');
-
- if (this.labels.length > 5) {
- return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
- labelsString,
- remainingLabelCount: this.labels.length - 5,
- });
- }
-
- return labelsString;
- },
- },
- methods: {
- handleClick() {
- this.$emit('onValueClick');
- },
- },
-};
-</script>
-
-<template>
- <div
- v-gl-tooltip.left.viewport
- :title="labelsList"
- class="sidebar-collapsed-icon"
- @click="handleClick"
- >
- <gl-icon name="labels" />
- <span>{{ labels.length }}</span>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql
index c130cc426dc..c442c17eb88 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql
@@ -2,6 +2,7 @@
query epicLabels($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
+ id
issuable: epic(iid: $iid) {
id
labels {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql
index 45fcb50732e..cb054e2968f 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql
@@ -1,8 +1,8 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
mutation updateEpicLabels($input: UpdateEpicInput!) {
- updateEpic(input: $input) {
- epic {
+ updateIssuableLabels: updateEpic(input: $input) {
+ issuable: epic {
id
labels {
nodes {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql
index e471d279b24..2904857270e 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql
@@ -2,6 +2,7 @@
query issueLabels($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
+ id
issuable: issue(iid: $iid) {
id
labels {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql
index dd80e89c8a7..e0cdfd91658 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql
@@ -2,6 +2,7 @@
query mergeRequestLabels($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
+ id
issuable: mergeRequest(iid: $iid) {
id
labels {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
index 97a65c13933..3adda69b892 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
@@ -2,14 +2,13 @@
import { debounce } from 'lodash';
import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
import createFlash from '~/flash';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { issuableLabelsQueries } from '~/sidebar/constants';
import { DEBOUNCE_DROPDOWN_DELAY, DropdownVariant } from './constants';
import DropdownContents from './dropdown_contents.vue';
import DropdownValue from './dropdown_value.vue';
-import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import {
isDropdownVariantSidebar,
isDropdownVariantStandalone,
@@ -20,7 +19,6 @@ export default {
components: {
DropdownValue,
DropdownContents,
- DropdownValueCollapsed,
SidebarEditableItem,
},
inject: {
@@ -225,15 +223,13 @@ export default {
variables: { input: inputVariables },
})
.then(({ data }) => {
- const { mutationName } = issuableLabelsQueries[this.issuableType];
-
- if (data[mutationName]?.errors?.length) {
+ if (data.updateIssuableLabels?.errors?.length) {
throw new Error();
}
this.$emit('updateSelectedLabels', {
- id: data[mutationName]?.[this.issuableType]?.id,
- labels: data[mutationName]?.[this.issuableType]?.labels?.nodes,
+ id: data.updateIssuableLabels?.issuable?.id,
+ labels: data.updateIssuableLabels?.issuable?.labels?.nodes,
});
})
.catch((error) =>
@@ -288,18 +284,14 @@ export default {
<template>
<div
- class="labels-select-wrapper position-relative"
+ class="labels-select-wrapper gl-relative"
:class="{
'is-standalone': isDropdownVariantStandalone(variant),
'is-embedded': isDropdownVariantEmbedded(variant),
}"
+ data-qa-selector="labels_block"
>
<template v-if="isDropdownVariantSidebar(variant)">
- <dropdown-value-collapsed
- ref="dropdownButtonCollapsed"
- :labels="issuableLabels"
- @onValueClick="handleCollapsedValueClick"
- />
<sidebar-editable-item
ref="editable"
:title="__('Labels')"
@@ -315,6 +307,7 @@ export default {
:labels-filter-base-path="labelsFilterBasePath"
:labels-filter-param="labelsFilterParam"
@onLabelRemove="handleLabelRemove"
+ @onCollapsedValueClick="handleCollapsedValueClick"
>
<slot></slot>
</dropdown-value>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql
index d99fc125012..bb6c7181e5c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql
@@ -7,6 +7,7 @@ query alertAssignees(
$iid: String!
) {
workspace: project(fullPath: $fullPath) {
+ id
issuable: alertManagementAlert(domain: $domain, iid: $iid) {
iid
assignees {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
index 93b9833bb7d..be270e440ed 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
@@ -4,6 +4,7 @@
query issueAssignees($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: issue(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
index 48787305459..96a40e597ee 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
@@ -4,6 +4,7 @@
query issueParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: issue(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
index 53f7381760e..81e19e48d75 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
@@ -3,6 +3,7 @@
query getMrAssignees($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
+ id
issuable: mergeRequest(iid: $iid) {
id
assignees {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
index 6adbd4098f2..3496d5f4a2e 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
@@ -3,6 +3,7 @@
query getMrParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
+ id
issuable: mergeRequest(iid: $iid) {
id
participants {
diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue
index fdf0c9baee3..8a0fef36079 100644
--- a/app/assets/javascripts/vue_shared/components/source_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/source_editor.vue
@@ -96,6 +96,7 @@ export default {
:id="`source-editor-${fileGlobalId}`"
ref="editor"
data-editor-loading
+ data-qa-selector="source_editor_container"
@[$options.readyEvent]="$emit($options.readyEvent)"
>
<pre class="editor-loading-content">{{ value }}</pre>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer.vue
new file mode 100644
index 00000000000..8f0d051543f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer.vue
@@ -0,0 +1,88 @@
+<script>
+import { GlSafeHtmlDirective } from '@gitlab/ui';
+import LineNumbers from '~/vue_shared/components/line_numbers.vue';
+
+export default {
+ components: {
+ LineNumbers,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ content: {
+ type: String,
+ required: true,
+ },
+ language: {
+ type: String,
+ required: false,
+ default: 'plaintext',
+ },
+ autoDetect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ languageDefinition: null,
+ hljs: null,
+ };
+ },
+ computed: {
+ lineNumbers() {
+ return this.content.split('\n').length;
+ },
+ highlightedContent() {
+ let highlightedContent;
+
+ if (this.hljs) {
+ if (this.autoDetect) {
+ highlightedContent = this.hljs.highlightAuto(this.content).value;
+ } else if (this.languageDefinition) {
+ highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value;
+ }
+ }
+
+ return highlightedContent;
+ },
+ },
+ async mounted() {
+ this.hljs = await this.loadHighlightJS();
+
+ if (!this.autoDetect) {
+ this.languageDefinition = await this.loadLanguage();
+ }
+ },
+ methods: {
+ loadHighlightJS() {
+ // With auto-detect enabled we load all common languages else we load only the core (smallest footprint)
+ return this.autoDetect ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
+ },
+ async loadLanguage() {
+ let languageDefinition;
+
+ try {
+ languageDefinition = await import(`highlight.js/lib/languages/${this.language}`);
+ this.hljs.registerLanguage(this.language, languageDefinition.default);
+ } catch (message) {
+ this.$emit('error', message);
+ }
+
+ return languageDefinition;
+ },
+ },
+ userColorScheme: window.gon.user_color_scheme,
+};
+</script>
+<template>
+ <div class="file-content code" :class="$options.userColorScheme">
+ <line-numbers :lines="lineNumbers" />
+ <pre
+ class="code gl-pl-3!"
+ ><code v-safe-html="highlightedContent" class="gl-white-space-pre-wrap!"></code>
+ </pre>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js
deleted file mode 100644
index 00aa5519ec6..00000000000
--- a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-import '@gitlab/ui/dist/utility_classes.css';
-import UsageGraph from './usage_graph.vue';
-
-export default {
- component: UsageGraph,
- title: 'vue_shared/components/storage_counter/usage_graph',
-};
-
-const Template = (args, { argTypes }) => ({
- components: { UsageGraph },
- props: Object.keys(argTypes),
- template: '<usage-graph v-bind="$props" />',
-});
-
-export const Default = Template.bind({});
-Default.argTypes = {
- rootStorageStatistics: {
- description: 'The statistics object with all its fields',
- type: { name: 'object', required: true },
- defaultValue: {
- buildArtifactsSize: 400000,
- pipelineArtifactsSize: 38000,
- lfsObjectsSize: 4800000,
- packagesSize: 3800000,
- repositorySize: 39000000,
- snippetsSize: 2000112,
- storageSize: 39930000,
- uploadsSize: 7000,
- wikiSize: 300000,
- },
- },
- limit: {
- description:
- 'When a limit is set, users will see how much of their storage usage (limit) is used. In case the limit is 0 or the current usage exceeds the limit, it just renders the distribution',
- defaultValue: 0,
- },
-};
diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue
deleted file mode 100644
index c33d065ff4b..00000000000
--- a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue
+++ /dev/null
@@ -1,148 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { s__ } from '~/locale';
-
-export default {
- components: {
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- rootStorageStatistics: {
- required: true,
- type: Object,
- },
- limit: {
- required: true,
- type: Number,
- },
- },
- computed: {
- storageTypes() {
- const {
- buildArtifactsSize,
- pipelineArtifactsSize,
- lfsObjectsSize,
- packagesSize,
- repositorySize,
- storageSize,
- wikiSize,
- snippetsSize,
- uploadsSize,
- } = this.rootStorageStatistics;
- const artifactsSize = buildArtifactsSize + pipelineArtifactsSize;
-
- if (storageSize === 0) {
- return null;
- }
-
- return [
- {
- name: s__('UsageQuota|Repositories'),
- style: this.usageStyle(this.barRatio(repositorySize)),
- class: 'gl-bg-data-viz-blue-500',
- size: repositorySize,
- },
- {
- name: s__('UsageQuota|LFS Objects'),
- style: this.usageStyle(this.barRatio(lfsObjectsSize)),
- class: 'gl-bg-data-viz-orange-600',
- size: lfsObjectsSize,
- },
- {
- name: s__('UsageQuota|Packages'),
- style: this.usageStyle(this.barRatio(packagesSize)),
- class: 'gl-bg-data-viz-aqua-500',
- size: packagesSize,
- },
- {
- name: s__('UsageQuota|Artifacts'),
- style: this.usageStyle(this.barRatio(artifactsSize)),
- class: 'gl-bg-data-viz-green-600',
- size: artifactsSize,
- tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'),
- },
- {
- name: s__('UsageQuota|Wikis'),
- style: this.usageStyle(this.barRatio(wikiSize)),
- class: 'gl-bg-data-viz-magenta-500',
- size: wikiSize,
- },
- {
- name: s__('UsageQuota|Snippets'),
- style: this.usageStyle(this.barRatio(snippetsSize)),
- class: 'gl-bg-data-viz-orange-800',
- size: snippetsSize,
- },
- {
- name: s__('UsageQuota|Uploads'),
- style: this.usageStyle(this.barRatio(uploadsSize)),
- class: 'gl-bg-data-viz-aqua-700',
- size: uploadsSize,
- },
- ]
- .filter((data) => data.size !== 0)
- .sort((a, b) => b.size - a.size);
- },
- },
- methods: {
- formatSize(size) {
- return numberToHumanSize(size);
- },
- usageStyle(ratio) {
- return { flex: ratio };
- },
- barRatio(size) {
- let max = this.rootStorageStatistics.storageSize;
-
- if (this.limit !== 0 && max <= this.limit) {
- max = this.limit;
- }
-
- return size / max;
- },
- },
-};
-</script>
-<template>
- <div v-if="storageTypes" class="gl-display-flex gl-flex-direction-column w-100">
- <div class="gl-h-6 gl-my-5 gl-bg-gray-50 gl-rounded-base gl-display-flex">
- <div
- v-for="storageType in storageTypes"
- :key="storageType.name"
- class="storage-type-usage gl-h-full gl-display-inline-block"
- :class="storageType.class"
- :style="storageType.style"
- data-testid="storage-type-usage"
- ></div>
- </div>
- <div class="row py-0">
- <div
- v-for="storageType in storageTypes"
- :key="storageType.name"
- class="col-md-auto gl-display-flex gl-align-items-center"
- data-testid="storage-type-legend"
- >
- <div class="gl-h-2 gl-w-5 gl-mr-2 gl-display-inline-block" :class="storageType.class"></div>
- <span class="gl-mr-2 gl-font-weight-bold gl-font-sm">
- {{ storageType.name }}
- </span>
- <span class="gl-text-gray-500 gl-font-sm">
- {{ formatSize(storageType.size) }}
- </span>
- <span
- v-if="storageType.tooltip"
- v-gl-tooltip
- :title="storageType.tooltip"
- :aria-label="storageType.tooltip"
- class="gl-ml-2"
- >
- <gl-icon name="question" :size="12" />
- </span>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
deleted file mode 100644
index c5fdb5fc242..00000000000
--- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
+++ /dev/null
@@ -1,69 +0,0 @@
-<script>
-import { GlTooltipDirective as GlTooltip } from '@gitlab/ui';
-import { isFunction } from 'lodash';
-import { hasHorizontalOverflow } from '~/lib/utils/dom_utils';
-
-export default {
- directives: {
- GlTooltip,
- },
- props: {
- title: {
- type: String,
- required: false,
- default: '',
- },
- placement: {
- type: String,
- required: false,
- default: 'top',
- },
- truncateTarget: {
- type: [String, Function],
- required: false,
- default: '',
- },
- },
- data() {
- return {
- showTooltip: false,
- };
- },
- watch: {
- title() {
- // Wait on $nextTick in case of slot width changes
- this.$nextTick(this.updateTooltip);
- },
- },
- mounted() {
- this.updateTooltip();
- },
- methods: {
- selectTarget() {
- if (isFunction(this.truncateTarget)) {
- return this.truncateTarget(this.$el);
- } else if (this.truncateTarget === 'child') {
- return this.$el.childNodes[0];
- }
-
- return this.$el;
- },
- updateTooltip() {
- const target = this.selectTarget();
- this.showTooltip = hasHorizontalOverflow(target);
- },
- },
-};
-</script>
-
-<template>
- <span
- v-if="showTooltip"
- v-gl-tooltip="{ placement }"
- :title="title"
- class="js-show-tooltip gl-min-w-0"
- >
- <slot></slot>
- </span>
- <span v-else class="gl-min-w-0"> <slot></slot> </span>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
new file mode 100644
index 00000000000..f27901a30a9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
@@ -0,0 +1,88 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import TooltipOnTruncate from './tooltip_on_truncate.vue';
+
+const defaultWidth = '250px';
+
+export default {
+ component: TooltipOnTruncate,
+ title: 'vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue',
+};
+
+const createStory = ({ ...options }) => {
+ return (_, { argTypes }) => {
+ const comp = {
+ components: { TooltipOnTruncate },
+ props: Object.keys(argTypes),
+ template: `
+ <div class="gl-bg-blue-50" :style="{ width }">
+ <tooltip-on-truncate :title="title" :placement="placement" class="gl-display-block gl-text-truncate">
+ {{title}}
+ </tooltip-on-truncate>
+ </div>
+ `,
+ ...options,
+ };
+
+ return comp;
+ };
+};
+
+export const Default = createStory();
+Default.args = {
+ width: defaultWidth,
+ title: 'Hover on this text to see the content in a tooltip.',
+};
+
+export const NoOverflow = createStory();
+NoOverflow.args = {
+ width: defaultWidth,
+ title: "Short text doesn't need a tooltip.",
+};
+
+export const Placement = createStory();
+Placement.args = {
+ width: defaultWidth,
+ title: 'Use `placement="right"` to display this tooltip at the right.',
+ placement: 'right',
+};
+
+const TIMEOUT_S = 3;
+
+export const LiveUpdates = createStory({
+ props: ['width', 'placement'],
+ data() {
+ return {
+ title: `(loading in ${TIMEOUT_S}s)`,
+ };
+ },
+ mounted() {
+ setTimeout(() => {
+ this.title = 'Content updated! The content is now overflowing so we use a tooltip!';
+ }, TIMEOUT_S * 1000);
+ },
+});
+LiveUpdates.args = {
+ width: defaultWidth,
+};
+LiveUpdates.argTypes = {
+ title: {
+ control: false,
+ },
+};
+
+export const TruncateTarget = createStory({
+ template: `
+ <div class="gl-bg-black" :style="{ width }">
+ <tooltip-on-truncate class="gl-display-flex" :truncate-target="truncateTarget" :title="title">
+ <div class="gl-m-5 gl-bg-blue-50 gl-text-truncate">
+ {{ title }}
+ </div>
+ </tooltip-on-truncate>
+ </div>
+ `,
+});
+TruncateTarget.args = {
+ width: defaultWidth,
+ truncateTarget: 'child',
+ title: 'Wrap in container and use `truncate-target="child"` prop.',
+};
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue
new file mode 100644
index 00000000000..09414e679bb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue
@@ -0,0 +1,85 @@
+<script>
+import { GlTooltipDirective, GlResizeObserverDirective } from '@gitlab/ui';
+import { isFunction, debounce } from 'lodash';
+import { hasHorizontalOverflow } from '~/lib/utils/dom_utils';
+
+const UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS = 300;
+
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlResizeObserver: GlResizeObserverDirective,
+ },
+ props: {
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ placement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ truncateTarget: {
+ type: [String, Function],
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ tooltipDisabled: true,
+ };
+ },
+ computed: {
+ classes() {
+ if (this.tooltipDisabled) {
+ return '';
+ }
+ return 'js-show-tooltip';
+ },
+ tooltip() {
+ return {
+ title: this.title,
+ placement: this.placement,
+ disabled: this.tooltipDisabled,
+ };
+ },
+ },
+ watch: {
+ title() {
+ // Wait on $nextTick in case the slot width changes
+ this.$nextTick(this.updateTooltip);
+ },
+ },
+ created() {
+ this.updateTooltipDebounced = debounce(this.updateTooltip, UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS);
+ },
+ mounted() {
+ this.updateTooltip();
+ },
+ methods: {
+ selectTarget() {
+ if (isFunction(this.truncateTarget)) {
+ return this.truncateTarget(this.$el);
+ } else if (this.truncateTarget === 'child') {
+ return this.$el.childNodes[0];
+ }
+ return this.$el;
+ },
+ updateTooltip() {
+ this.tooltipDisabled = !hasHorizontalOverflow(this.selectTarget());
+ },
+ onResize() {
+ this.updateTooltipDebounced();
+ },
+ },
+};
+</script>
+
+<template>
+ <span v-gl-tooltip="tooltip" v-gl-resize-observer="onResize" :class="classes" class="gl-min-w-0">
+ <slot></slot>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue
new file mode 100644
index 00000000000..f4cbaba9313
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue
@@ -0,0 +1,44 @@
+<script>
+import IssuableForm from './issuable_form.vue';
+
+export default {
+ components: {
+ IssuableForm,
+ },
+ props: {
+ descriptionPreviewPath: {
+ type: String,
+ required: true,
+ },
+ descriptionHelpPath: {
+ type: String,
+ required: true,
+ },
+ labelsFetchPath: {
+ type: String,
+ required: true,
+ },
+ labelsManagePath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issuable-create-container">
+ <slot name="title"></slot>
+ <hr class="gl-mt-0" />
+ <issuable-form
+ :description-preview-path="descriptionPreviewPath"
+ :description-help-path="descriptionHelpPath"
+ :labels-fetch-path="labelsFetchPath"
+ :labels-manage-path="labelsManagePath"
+ >
+ <template #actions="issuableMeta">
+ <slot name="actions" v-bind="issuableMeta"></slot>
+ </template>
+ </issuable-form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
new file mode 100644
index 00000000000..c216a05bdb0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
@@ -0,0 +1,128 @@
+<script>
+import { GlForm, GlFormInput } from '@gitlab/ui';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
+import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
+
+export default {
+ LabelSelectVariant: DropdownVariant,
+ components: {
+ GlForm,
+ GlFormInput,
+ MarkdownField,
+ LabelsSelect,
+ },
+ props: {
+ descriptionPreviewPath: {
+ type: String,
+ required: true,
+ },
+ descriptionHelpPath: {
+ type: String,
+ required: true,
+ },
+ labelsFetchPath: {
+ type: String,
+ required: true,
+ },
+ labelsManagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ issuableTitle: '',
+ issuableDescription: '',
+ selectedLabels: [],
+ };
+ },
+ methods: {
+ handleUpdateSelectedLabels(labels) {
+ if (labels.length) {
+ this.selectedLabels = labels;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form class="common-note-form gfm-form" @submit.stop.prevent>
+ <div data-testid="issuable-title" class="form-group row">
+ <label for="issuable-title" class="col-form-label col-sm-2">{{ __('Title') }}</label>
+ <div class="col-sm-10">
+ <gl-form-input
+ id="issuable-title"
+ v-model="issuableTitle"
+ :autofocus="true"
+ :placeholder="__('Title')"
+ />
+ </div>
+ </div>
+ <div data-testid="issuable-description" class="form-group row">
+ <label for="issuable-description" class="col-form-label col-sm-2">{{
+ __('Description')
+ }}</label>
+ <div class="col-sm-10">
+ <markdown-field
+ :markdown-preview-path="descriptionPreviewPath"
+ :markdown-docs-path="descriptionHelpPath"
+ :add-spacing-classes="false"
+ :show-suggest-popover="true"
+ :textarea-value="issuableDescription"
+ >
+ <template #textarea>
+ <textarea
+ id="issuable-description"
+ ref="textarea"
+ v-model="issuableDescription"
+ dir="auto"
+ class="note-textarea qa-issuable-form-description rspec-issuable-form-description js-gfm-input js-autosize markdown-area"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files hereā€¦')"
+ ></textarea>
+ </template>
+ </markdown-field>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-lg-6">
+ <div data-testid="issuable-labels" class="form-group row">
+ <label for="issuable-labels" class="col-form-label col-md-2 col-lg-4">{{
+ __('Labels')
+ }}</label>
+ <div class="col-md-8 col-sm-10">
+ <div class="issuable-form-select-holder">
+ <labels-select
+ :allow-label-edit="true"
+ :allow-label-create="true"
+ :allow-multiselect="true"
+ :allow-scoped-labels="true"
+ :labels-fetch-path="labelsFetchPath"
+ :labels-manage-path="labelsManagePath"
+ :selected-labels="selectedLabels"
+ :labels-list-title="__('Select label')"
+ :footer-create-label-title="__('Create project label')"
+ :footer-manage-label-title="__('Manage project labels')"
+ :variant="$options.LabelSelectVariant.Embedded"
+ @updateSelectedLabels="handleUpdateSelectedLabels"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div
+ data-testid="issuable-create-actions"
+ class="footer-block row-content-block gl-display-flex"
+ >
+ <slot
+ name="actions"
+ :issuable-title="issuableTitle"
+ :issuable-description="issuableDescription"
+ :selected-labels="selectedLabels"
+ ></slot>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue
new file mode 100644
index 00000000000..5ca9e50d854
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue
@@ -0,0 +1,35 @@
+<script>
+export default {
+ props: {
+ expanded: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ watch: {
+ expanded(value) {
+ const layoutPageEl = document.querySelector('.layout-page');
+
+ if (layoutPageEl) {
+ layoutPageEl.classList.toggle('right-sidebar-expanded', value);
+ layoutPageEl.classList.toggle('right-sidebar-collapsed', !value);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <aside
+ :class="{ 'right-sidebar-expanded': expanded, 'right-sidebar-collapsed': !expanded }"
+ class="issues-bulk-update right-sidebar"
+ aria-live="polite"
+ >
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-p-4 gl-border-b-1 gl-border-b-solid gl-border-gray-100"
+ >
+ <slot name="bulk-edit-actions"></slot>
+ </div>
+ <slot name="sidebar-items"></slot>
+ </aside>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
new file mode 100644
index 00000000000..0bb0e0d9fb0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
@@ -0,0 +1,303 @@
+<script>
+import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/datetime_utility';
+import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
+import { __, n__, sprintf } from '~/locale';
+import IssuableAssignees from '~/issuable/components/issue_assignees.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+export default {
+ components: {
+ GlLink,
+ GlIcon,
+ GlLabel,
+ GlFormCheckbox,
+ GlSprintf,
+ IssuableAssignees,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ issuableSymbol: {
+ type: String,
+ required: true,
+ },
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ enableLabelPermalinks: {
+ type: Boolean,
+ required: true,
+ },
+ labelFilterParam: {
+ type: String,
+ required: false,
+ default: 'label_name',
+ },
+ showCheckbox: {
+ type: Boolean,
+ required: true,
+ },
+ checked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ issuableId() {
+ return getIdFromGraphQLId(this.issuable.id);
+ },
+ createdInPastDay() {
+ const createdSecondsAgo = differenceInSeconds(new Date(this.issuable.createdAt), new Date());
+ return createdSecondsAgo < SECONDS_IN_DAY;
+ },
+ author() {
+ return this.issuable.author;
+ },
+ webUrl() {
+ return this.issuable.gitlabWebUrl || this.issuable.webUrl;
+ },
+ authorId() {
+ return getIdFromGraphQLId(this.author.id);
+ },
+ isIssuableUrlExternal() {
+ return isExternal(this.webUrl);
+ },
+ reference() {
+ return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`;
+ },
+ labels() {
+ return this.issuable.labels?.nodes || this.issuable.labels || [];
+ },
+ labelIdsString() {
+ return JSON.stringify(this.labels.map((label) => getIdFromGraphQLId(label.id)));
+ },
+ assignees() {
+ return this.issuable.assignees?.nodes || this.issuable.assignees || [];
+ },
+ createdAt() {
+ return getTimeago().format(this.issuable.createdAt);
+ },
+ updatedAt() {
+ return sprintf(__('updated %{timeAgo}'), {
+ timeAgo: getTimeago().format(this.issuable.updatedAt),
+ });
+ },
+ issuableTitleProps() {
+ if (this.isIssuableUrlExternal) {
+ return {
+ target: '_blank',
+ };
+ }
+ return {};
+ },
+ taskStatus() {
+ const { completedCount, count } = this.issuable.taskCompletionStatus || {};
+ if (!count) {
+ return undefined;
+ }
+
+ return sprintf(
+ n__(
+ '%{completedCount} of %{count} task completed',
+ '%{completedCount} of %{count} tasks completed',
+ count,
+ ),
+ { completedCount, count },
+ );
+ },
+ notesCount() {
+ return this.issuable.userDiscussionsCount ?? this.issuable.userNotesCount;
+ },
+ showDiscussions() {
+ return typeof this.notesCount === 'number';
+ },
+ showIssuableMeta() {
+ return Boolean(
+ this.hasSlotContents('status') || this.showDiscussions || this.issuable.assignees,
+ );
+ },
+ issuableNotesLink() {
+ return setUrlFragment(this.webUrl, 'notes');
+ },
+ },
+ methods: {
+ hasSlotContents(slotName) {
+ return Boolean(this.$slots[slotName]);
+ },
+ scopedLabel(label) {
+ return isScopedLabel(label);
+ },
+ labelTitle(label) {
+ return label.title || label.name;
+ },
+ labelTarget(label) {
+ if (this.enableLabelPermalinks) {
+ const value = encodeURIComponent(this.labelTitle(label));
+ return `?${this.labelFilterParam}[]=${value}`;
+ }
+ return '#';
+ },
+ /**
+ * This is needed as an independent method since
+ * when user changes current page, `$refs.authorLink`
+ * will be null until next page results are loaded & rendered.
+ */
+ getAuthorPopoverTarget() {
+ if (this.$refs.authorLink) {
+ return this.$refs.authorLink.$el;
+ }
+ return '';
+ },
+ },
+};
+</script>
+
+<template>
+ <li
+ :id="`issuable_${issuableId}`"
+ class="issue gl-display-flex! gl-px-5!"
+ :class="{ closed: issuable.closedAt, today: createdInPastDay }"
+ :data-labels="labelIdsString"
+ :data-qa-issue-id="issuableId"
+ >
+ <gl-form-checkbox
+ v-if="showCheckbox"
+ class="issue-check gl-mr-0"
+ :checked="checked"
+ :data-id="issuableId"
+ @input="$emit('checked-input', $event)"
+ >
+ <span class="gl-sr-only">{{ issuable.title }}</span>
+ </gl-form-checkbox>
+ <div class="issuable-main-info">
+ <div data-testid="issuable-title" class="issue-title title">
+ <gl-icon
+ v-if="issuable.confidential"
+ v-gl-tooltip
+ name="eye-slash"
+ :title="__('Confidential')"
+ :aria-label="__('Confidential')"
+ />
+ <gl-icon
+ v-if="issuable.hidden"
+ v-gl-tooltip
+ name="spam"
+ :title="__('This issue is hidden because its author has been banned')"
+ :aria-label="__('Hidden')"
+ />
+ <gl-link class="issue-title-text" dir="auto" :href="webUrl" v-bind="issuableTitleProps">
+ {{ issuable.title }}
+ <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" />
+ </gl-link>
+ <span
+ v-if="taskStatus"
+ class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3"
+ data-testid="task-status"
+ >
+ {{ taskStatus }}
+ </span>
+ </div>
+ <div class="issuable-info">
+ <slot v-if="hasSlotContents('reference')" name="reference"></slot>
+ <span v-else data-testid="issuable-reference" class="issuable-reference">
+ {{ reference }}
+ </span>
+ <span class="gl-display-none gl-sm-display-inline">
+ <span aria-hidden="true">&middot;</span>
+ <span class="issuable-authored gl-mr-3">
+ <gl-sprintf :message="__('created %{timeAgo} by %{author}')">
+ <template #timeAgo>
+ <span
+ v-gl-tooltip.bottom
+ :title="tooltipTitle(issuable.createdAt)"
+ data-testid="issuable-created-at"
+ >
+ {{ createdAt }}
+ </span>
+ </template>
+ <template #author>
+ <slot v-if="hasSlotContents('author')" name="author"></slot>
+ <gl-link
+ v-else
+ :data-user-id="authorId"
+ :data-username="author.username"
+ :data-name="author.name"
+ :data-avatar-url="author.avatarUrl"
+ :href="author.webUrl"
+ data-testid="issuable-author"
+ class="author-link js-user-link"
+ >
+ <span class="author">{{ author.name }}</span>
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ <slot name="timeframe"></slot>
+ </span>
+ &nbsp;
+ <span v-if="labels.length" role="group" :aria-label="__('Labels')">
+ <gl-label
+ v-for="(label, index) in labels"
+ :key="index"
+ :background-color="label.color"
+ :title="labelTitle(label)"
+ :description="label.description"
+ :scoped="scopedLabel(label)"
+ :target="labelTarget(label)"
+ :class="{ 'gl-ml-2': index }"
+ size="sm"
+ />
+ </span>
+ </div>
+ </div>
+ <div class="issuable-meta">
+ <ul v-if="showIssuableMeta" class="controls">
+ <li v-if="hasSlotContents('status')" class="issuable-status">
+ <slot name="status"></slot>
+ </li>
+ <li v-if="assignees.length">
+ <issuable-assignees
+ :assignees="assignees"
+ :icon-size="16"
+ :max-visible="4"
+ img-css-classes="gl-mr-2!"
+ class="gl-align-items-center gl-display-flex gl-ml-3"
+ />
+ </li>
+ <slot name="statistics"></slot>
+ <li
+ v-if="showDiscussions"
+ data-testid="issuable-discussions"
+ class="issuable-comments gl-display-none gl-sm-display-block"
+ >
+ <gl-link
+ v-gl-tooltip.top
+ :title="__('Comments')"
+ :href="issuableNotesLink"
+ :class="{ 'no-comments': !notesCount }"
+ class="gl-reset-color!"
+ >
+ <gl-icon name="comments" />
+ {{ notesCount }}
+ </gl-link>
+ </li>
+ </ul>
+ <div
+ v-gl-tooltip.bottom
+ class="gl-text-gray-500 gl-display-none gl-sm-display-inline-block"
+ :title="tooltipTitle(issuable.updatedAt)"
+ data-testid="issuable-updated-at"
+ >
+ {{ updatedAt }}
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
new file mode 100644
index 00000000000..2f8401b45f0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
@@ -0,0 +1,363 @@
+<script>
+import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+
+import { DEFAULT_SKELETON_COUNT } from '../constants';
+import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
+import IssuableItem from './issuable_item.vue';
+import IssuableTabs from './issuable_tabs.vue';
+
+const VueDraggable = () => import('vuedraggable');
+
+export default {
+ vueDraggableAttributes: {
+ animation: 200,
+ ghostClass: 'gl-visibility-hidden',
+ tag: 'ul',
+ },
+ components: {
+ GlAlert,
+ GlKeysetPagination,
+ GlSkeletonLoading,
+ IssuableTabs,
+ FilteredSearchBar,
+ IssuableItem,
+ IssuableBulkEditSidebar,
+ GlPagination,
+ VueDraggable,
+ },
+ props: {
+ namespace: {
+ type: String,
+ required: true,
+ },
+ recentSearchesStorageKey: {
+ type: String,
+ required: true,
+ },
+ searchInputPlaceholder: {
+ type: String,
+ required: true,
+ },
+ searchTokens: {
+ type: Array,
+ required: true,
+ },
+ sortOptions: {
+ type: Array,
+ required: true,
+ },
+ urlParams: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ initialFilterValue: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ initialSortBy: {
+ type: String,
+ required: false,
+ default: 'created_desc',
+ },
+ issuables: {
+ type: Array,
+ required: true,
+ },
+ tabs: {
+ type: Array,
+ required: true,
+ },
+ tabCounts: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ currentTab: {
+ type: String,
+ required: true,
+ },
+ issuableSymbol: {
+ type: String,
+ required: false,
+ default: '#',
+ },
+ issuablesLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showPaginationControls: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showBulkEditSidebar: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ defaultPageSize: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ totalItems: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ currentPage: {
+ type: Number,
+ required: false,
+ default: 1,
+ },
+ previousPage: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ nextPage: {
+ type: Number,
+ required: false,
+ default: 2,
+ },
+ enableLabelPermalinks: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ labelFilterParam: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ isManualOrdering: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ useKeysetPagination: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ hasNextPage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ hasPreviousPage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ error: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ checkedIssuables: {},
+ };
+ },
+ computed: {
+ skeletonItemCount() {
+ const { totalItems, defaultPageSize, currentPage } = this;
+ const totalPages = Math.ceil(totalItems / defaultPageSize);
+
+ if (totalPages) {
+ return currentPage < totalPages
+ ? defaultPageSize
+ : totalItems % defaultPageSize || defaultPageSize;
+ }
+ return DEFAULT_SKELETON_COUNT;
+ },
+ allIssuablesChecked() {
+ return this.bulkEditIssuables.length === this.issuables.length;
+ },
+ /**
+ * Returns all the checked issuables from `checkedIssuables` map.
+ */
+ bulkEditIssuables() {
+ return Object.keys(this.checkedIssuables).reduce((acc, issuableId) => {
+ if (this.checkedIssuables[issuableId].checked) {
+ acc.push(this.checkedIssuables[issuableId].issuable);
+ }
+ return acc;
+ }, []);
+ },
+ issuablesWrapper() {
+ return this.isManualOrdering ? VueDraggable : 'ul';
+ },
+ },
+ watch: {
+ issuables(list) {
+ this.checkedIssuables = list.reduce((acc, issuable) => {
+ const id = this.issuableId(issuable);
+ acc[id] = {
+ // By default, an issuable is not checked,
+ // But if `checkedIssuables` is already
+ // populated, use existing value.
+ checked:
+ typeof this.checkedIssuables[id] !== 'boolean'
+ ? false
+ : this.checkedIssuables[id].checked,
+ // We're caching issuable reference here
+ // for ease of populating in `bulkEditIssuables`.
+ issuable,
+ };
+ return acc;
+ }, {});
+ },
+ urlParams: {
+ deep: true,
+ immediate: true,
+ handler(params) {
+ if (Object.keys(params).length) {
+ updateHistory({
+ url: setUrlParams(params, window.location.href, true, false, true),
+ title: document.title,
+ replace: true,
+ });
+ }
+ },
+ },
+ },
+ methods: {
+ issuableId(issuable) {
+ return getIdFromGraphQLId(issuable.id) || issuable.iid || uniqueId();
+ },
+ issuableChecked(issuable) {
+ return this.checkedIssuables[this.issuableId(issuable)]?.checked;
+ },
+ handleIssuableCheckedInput(issuable, value) {
+ this.checkedIssuables[this.issuableId(issuable)].checked = value;
+ this.$emit('update-legacy-bulk-edit');
+ },
+ handleAllIssuablesCheckedInput(value) {
+ Object.keys(this.checkedIssuables).forEach((issuableId) => {
+ this.checkedIssuables[issuableId].checked = value;
+ });
+ this.$emit('update-legacy-bulk-edit');
+ },
+ handleVueDraggableUpdate({ newIndex, oldIndex }) {
+ this.$emit('reorder', { newIndex, oldIndex });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issuable-list-container">
+ <issuable-tabs
+ :tabs="tabs"
+ :tab-counts="tabCounts"
+ :current-tab="currentTab"
+ @click="$emit('click-tab', $event)"
+ >
+ <template #nav-actions>
+ <slot name="nav-actions"></slot>
+ </template>
+ </issuable-tabs>
+ <filtered-search-bar
+ :namespace="namespace"
+ :recent-searches-storage-key="recentSearchesStorageKey"
+ :search-input-placeholder="searchInputPlaceholder"
+ :tokens="searchTokens"
+ :sort-options="sortOptions"
+ :initial-filter-value="initialFilterValue"
+ :initial-sort-by="initialSortBy"
+ :show-checkbox="showBulkEditSidebar"
+ :checkbox-checked="allIssuablesChecked"
+ class="gl-flex-grow-1 gl-border-t-none row-content-block"
+ data-qa-selector="issuable_search_container"
+ @checked-input="handleAllIssuablesCheckedInput"
+ @onFilter="$emit('filter', $event)"
+ @onSort="$emit('sort', $event)"
+ />
+ <gl-alert v-if="error" variant="danger" @dismiss="$emit('dismiss-alert')">{{ error }}</gl-alert>
+ <issuable-bulk-edit-sidebar :expanded="showBulkEditSidebar">
+ <template #bulk-edit-actions>
+ <slot name="bulk-edit-actions" :checked-issuables="bulkEditIssuables"></slot>
+ </template>
+ <template #sidebar-items>
+ <slot name="sidebar-items" :checked-issuables="bulkEditIssuables"></slot>
+ </template>
+ </issuable-bulk-edit-sidebar>
+ <ul v-if="issuablesLoading" class="content-list">
+ <li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!">
+ <gl-skeleton-loading />
+ </li>
+ </ul>
+ <template v-else>
+ <component
+ :is="issuablesWrapper"
+ v-if="issuables.length > 0"
+ class="content-list issuable-list issues-list"
+ :class="{ 'manual-ordering': isManualOrdering }"
+ v-bind="$options.vueDraggableAttributes"
+ @update="handleVueDraggableUpdate"
+ >
+ <issuable-item
+ v-for="issuable in issuables"
+ :key="issuableId(issuable)"
+ :class="{ 'gl-cursor-grab': isManualOrdering }"
+ data-qa-selector="issuable_container"
+ :data-qa-issuable-title="issuable.title"
+ :issuable-symbol="issuableSymbol"
+ :issuable="issuable"
+ :enable-label-permalinks="enableLabelPermalinks"
+ :label-filter-param="labelFilterParam"
+ :show-checkbox="showBulkEditSidebar"
+ :checked="issuableChecked(issuable)"
+ @checked-input="handleIssuableCheckedInput(issuable, $event)"
+ >
+ <template #reference>
+ <slot name="reference" :issuable="issuable"></slot>
+ </template>
+ <template #author>
+ <slot name="author" :author="issuable.author"></slot>
+ </template>
+ <template #timeframe>
+ <slot name="timeframe" :issuable="issuable"></slot>
+ </template>
+ <template #status>
+ <slot name="status" :issuable="issuable"></slot>
+ </template>
+ <template #statistics>
+ <slot name="statistics" :issuable="issuable"></slot>
+ </template>
+ </issuable-item>
+ </component>
+ <slot v-else name="empty-state"></slot>
+ </template>
+
+ <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3">
+ <gl-keyset-pagination
+ :has-next-page="hasNextPage"
+ :has-previous-page="hasPreviousPage"
+ @next="$emit('next-page')"
+ @prev="$emit('previous-page')"
+ />
+ </div>
+ <gl-pagination
+ v-else-if="showPaginationControls"
+ :per-page="defaultPageSize"
+ :total-items="totalItems"
+ :value="currentPage"
+ :prev-page="previousPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-pagination gl-mt-3"
+ @input="$emit('page-change', $event)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue
new file mode 100644
index 00000000000..3ff87ba3c4f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlTabs,
+ GlTab,
+ GlBadge,
+ },
+ props: {
+ tabs: {
+ type: Array,
+ required: true,
+ },
+ tabCounts: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ currentTab: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ isTabActive(tabName) {
+ return tabName === this.currentTab;
+ },
+ isTabCountNumeric(tab) {
+ return Number.isInteger(this.tabCounts[tab.name]);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="top-area">
+ <gl-tabs
+ class="gl-display-flex gl-flex-grow-1 gl-p-0 gl-m-0 mobile-separator issuable-state-filters"
+ nav-class="gl-border-b-0"
+ >
+ <gl-tab
+ v-for="tab in tabs"
+ :key="tab.id"
+ :active="isTabActive(tab.name)"
+ @click="$emit('click', tab.name)"
+ >
+ <template #title>
+ <span :title="tab.titleTooltip" :data-qa-selector="`${tab.name}_issuables_tab`">
+ {{ tab.title }}
+ </span>
+ <gl-badge
+ v-if="tabCounts && isTabCountNumeric(tab)"
+ variant="muted"
+ size="sm"
+ class="gl-tab-counter-badge"
+ >
+ {{ tabCounts[tab.name] }}
+ </gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+ <div class="nav-controls">
+ <slot name="nav-actions"></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js
new file mode 100644
index 00000000000..773ad0f8e93
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js
@@ -0,0 +1,51 @@
+import { __ } from '~/locale';
+
+export const IssuableStates = {
+ Opened: 'opened',
+ Closed: 'closed',
+ All: 'all',
+};
+
+export const IssuableListTabs = [
+ {
+ id: 'state-opened',
+ name: IssuableStates.Opened,
+ title: __('Open'),
+ titleTooltip: __('Filter by issues that are currently opened.'),
+ },
+ {
+ id: 'state-closed',
+ name: IssuableStates.Closed,
+ title: __('Closed'),
+ titleTooltip: __('Filter by issues that are currently closed.'),
+ },
+ {
+ id: 'state-all',
+ name: IssuableStates.All,
+ title: __('All'),
+ titleTooltip: __('Show all issues.'),
+ },
+];
+
+export const AvailableSortOptions = [
+ {
+ id: 1,
+ title: __('Created date'),
+ sortDirection: {
+ descending: 'created_desc',
+ ascending: 'created_asc',
+ },
+ },
+ {
+ id: 2,
+ title: __('Last updated'),
+ sortDirection: {
+ descending: 'updated_desc',
+ ascending: 'updated_asc',
+ },
+ },
+];
+
+export const DEFAULT_PAGE_SIZE = 20;
+
+export const DEFAULT_SKELETON_COUNT = 5;
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
new file mode 100644
index 00000000000..05dc1650379
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
@@ -0,0 +1,194 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+import TaskList from '~/task_list';
+
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+import IssuableDescription from './issuable_description.vue';
+import IssuableEditForm from './issuable_edit_form.vue';
+import IssuableTitle from './issuable_title.vue';
+
+export default {
+ components: {
+ GlLink,
+ TimeAgoTooltip,
+ IssuableTitle,
+ IssuableDescription,
+ IssuableEditForm,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ statusBadgeClass: {
+ type: String,
+ required: true,
+ },
+ statusIcon: {
+ type: String,
+ required: true,
+ },
+ enableEdit: {
+ type: Boolean,
+ required: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: true,
+ },
+ enableAutosave: {
+ type: Boolean,
+ required: true,
+ },
+ enableZenMode: {
+ type: Boolean,
+ required: true,
+ },
+ enableTaskList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ editFormVisible: {
+ type: Boolean,
+ required: true,
+ },
+ showFieldTitle: {
+ type: Boolean,
+ required: true,
+ },
+ descriptionPreviewPath: {
+ type: String,
+ required: true,
+ },
+ descriptionHelpPath: {
+ type: String,
+ required: true,
+ },
+ taskListUpdatePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ taskListLockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ },
+ computed: {
+ isUpdated() {
+ return Boolean(this.issuable.updatedAt);
+ },
+ updatedBy() {
+ return this.issuable.updatedBy;
+ },
+ },
+ watch: {
+ /**
+ * When user switches between view and edit modes,
+ * taskList instance becomes invalid so whenever
+ * view mode is rendered, we need to re-initialize
+ * taskList to ensure the behaviour functional.
+ */
+ editFormVisible(value) {
+ if (!value) {
+ this.$nextTick(() => {
+ this.initTaskList();
+ });
+ }
+ },
+ },
+ mounted() {
+ if (this.enableEdit && this.enableTaskList) {
+ this.initTaskList();
+ }
+ },
+ methods: {
+ initTaskList() {
+ this.taskList = new TaskList({
+ /**
+ * We have hard-coded dataType to `issue`
+ * as currently only `issue` types can handle
+ * task-lists, however, we can still use
+ * task lists in Issue, Test Cases and Incidents
+ * as all of those are derived from `issue`.
+ */
+ dataType: 'issue',
+ fieldName: 'description',
+ lockVersion: this.taskListLockVersion,
+ selector: '.js-detail-page-description',
+ onSuccess: this.handleTaskListUpdateSuccess.bind(this),
+ onError: this.handleTaskListUpdateFailure.bind(this),
+ });
+ },
+ handleTaskListUpdateSuccess(updatedIssuable) {
+ this.$emit('task-list-update-success', updatedIssuable);
+ },
+ handleTaskListUpdateFailure() {
+ this.$emit('task-list-update-failure');
+ },
+ handleKeydownTitle(e, issuableMeta) {
+ this.$emit('keydown-title', e, issuableMeta);
+ },
+ handleKeydownDescription(e, issuableMeta) {
+ this.$emit('keydown-description', e, issuableMeta);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issue-details issuable-details">
+ <div class="detail-page-description js-detail-page-description content-block">
+ <issuable-edit-form
+ v-if="editFormVisible"
+ :issuable="issuable"
+ :enable-autocomplete="enableAutocomplete"
+ :enable-autosave="enableAutosave"
+ :enable-zen-mode="enableZenMode"
+ :show-field-title="showFieldTitle"
+ :description-preview-path="descriptionPreviewPath"
+ :description-help-path="descriptionHelpPath"
+ @keydown-title="handleKeydownTitle"
+ @keydown-description="handleKeydownDescription"
+ >
+ <template #edit-form-actions="issuableMeta">
+ <slot name="edit-form-actions" v-bind="issuableMeta"></slot>
+ </template>
+ </issuable-edit-form>
+ <template v-else>
+ <issuable-title
+ :issuable="issuable"
+ :status-badge-class="statusBadgeClass"
+ :status-icon="statusIcon"
+ :enable-edit="enableEdit"
+ @edit-issuable="$emit('edit-issuable', $event)"
+ >
+ <template #status-badge>
+ <slot name="status-badge"></slot>
+ </template>
+ </issuable-title>
+ <issuable-description
+ v-if="issuable.descriptionHtml"
+ :issuable="issuable"
+ :enable-task-list="enableTaskList"
+ :can-edit="enableEdit"
+ :task-list-update-path="taskListUpdatePath"
+ />
+ <small v-if="isUpdated" class="edited-text gl-font-sm!">
+ {{ __('Edited') }}
+ <time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" />
+ <span v-if="updatedBy">
+ {{ __('by') }}
+ <gl-link :href="updatedBy.webUrl" class="author-link gl-font-sm!">
+ <span>{{ updatedBy.name }}</span>
+ </gl-link>
+ </span>
+ </small>
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue
new file mode 100644
index 00000000000..f57b5b2deb4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import $ from 'jquery';
+import '~/behaviors/markdown/render_gfm';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ enableTaskList: {
+ type: Boolean,
+ required: true,
+ },
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ taskListUpdatePath: {
+ type: String,
+ required: true,
+ },
+ },
+ mounted() {
+ this.renderGFM();
+ },
+ methods: {
+ renderGFM() {
+ $(this.$refs.gfmContainer).renderGFM();
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="description" :class="{ 'js-task-list-container': canEdit && enableTaskList }">
+ <div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div>
+ <textarea
+ v-if="issuable.description && enableTaskList"
+ ref="textarea"
+ :value="issuable.description"
+ :data-update-url="taskListUpdatePath"
+ class="gl-display-none js-task-list-field"
+ dir="auto"
+ >
+ </textarea>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue
new file mode 100644
index 00000000000..5858af6cc51
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue
@@ -0,0 +1,15 @@
+<script>
+export default {
+ name: 'IssuableDiscussion',
+};
+</script>
+
+<template>
+ <section class="issuable-discussion">
+ <div>
+ <ul class="notes main-notes-list timeline">
+ <slot name="discussion"></slot>
+ </ul>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
new file mode 100644
index 00000000000..33dca3e9332
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
@@ -0,0 +1,167 @@
+<script>
+import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import $ from 'jquery';
+
+import Autosave from '~/autosave';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import ZenMode from '~/zen_mode';
+
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ MarkdownField,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: true,
+ },
+ enableAutosave: {
+ type: Boolean,
+ required: true,
+ },
+ enableZenMode: {
+ type: Boolean,
+ required: true,
+ },
+ showFieldTitle: {
+ type: Boolean,
+ required: true,
+ },
+ descriptionPreviewPath: {
+ type: String,
+ required: true,
+ },
+ descriptionHelpPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ title: '',
+ description: '',
+ };
+ },
+ watch: {
+ issuable: {
+ handler(value) {
+ this.title = value?.title || '';
+ this.description = value?.description || '';
+ },
+ deep: true,
+ immediate: true,
+ },
+ },
+ created() {
+ eventHub.$on('update.issuable', this.resetAutosave);
+ eventHub.$on('close.form', this.resetAutosave);
+ },
+ mounted() {
+ if (this.enableAutosave) this.initAutosave();
+
+ // eslint-disable-next-line no-new
+ if (this.enableZenMode) new ZenMode();
+ },
+ beforeDestroy() {
+ eventHub.$off('update.issuable', this.resetAutosave);
+ eventHub.$off('close.form', this.resetAutosave);
+ },
+ methods: {
+ initAutosave() {
+ const { titleInput, descriptionInput } = this.$refs;
+
+ if (!titleInput || !descriptionInput) return;
+
+ this.autosaveTitle = new Autosave($(titleInput.$el), [
+ document.location.pathname,
+ document.location.search,
+ 'title',
+ ]);
+
+ this.autosaveDescription = new Autosave($(descriptionInput.$el), [
+ document.location.pathname,
+ document.location.search,
+ 'description',
+ ]);
+ },
+ resetAutosave() {
+ this.autosaveTitle.reset();
+ this.autosaveDescription.reset();
+ },
+ handleKeydown(e, inputType) {
+ this.$emit(`keydown-${inputType}`, e, {
+ issuableTitle: this.title,
+ issuableDescription: this.description,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form>
+ <gl-form-group
+ data-testid="title"
+ :label="__('Title')"
+ :label-sr-only="!showFieldTitle"
+ label-for="issuable-title"
+ class="col-12 gl-px-0"
+ >
+ <gl-form-input
+ id="issuable-title"
+ ref="titleInput"
+ v-model.trim="title"
+ :placeholder="__('Title')"
+ :aria-label="__('Title')"
+ :autofocus="true"
+ class="qa-title-input"
+ @keydown="handleKeydown($event, 'title')"
+ />
+ </gl-form-group>
+ <gl-form-group
+ data-testid="description"
+ :label="__('Description')"
+ :label-sr-only="!showFieldTitle"
+ label-for="issuable-description"
+ label-class="gl-pb-0!"
+ class="col-12 gl-px-0 common-note-form"
+ >
+ <markdown-field
+ :markdown-preview-path="descriptionPreviewPath"
+ :markdown-docs-path="descriptionHelpPath"
+ :enable-autocomplete="enableAutocomplete"
+ :textarea-value="description"
+ >
+ <template #textarea>
+ <textarea
+ id="issuable-description"
+ ref="descriptionInput"
+ v-model="description"
+ :data-supports-quick-actions="enableAutocomplete"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files hereā€¦')"
+ class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea"
+ dir="auto"
+ @keydown="handleKeydown($event, 'description')"
+ ></textarea>
+ </template>
+ </markdown-field>
+ </gl-form-group>
+ <div data-testid="actions" class="col-12 gl-mt-3 gl-mb-3 gl-px-0 clearfix">
+ <slot
+ name="edit-form-actions"
+ :issuable-title="title"
+ :issuable-description="description"
+ ></slot>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
new file mode 100644
index 00000000000..d7da533d055
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
@@ -0,0 +1,152 @@
+<script>
+import { GlIcon, GlButton, GlTooltipDirective, GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui';
+
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { isExternal } from '~/lib/utils/url_utility';
+import { n__, sprintf } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ GlIcon,
+ GlButton,
+ GlAvatarLink,
+ GlAvatarLabeled,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ createdAt: {
+ type: String,
+ required: true,
+ },
+ author: {
+ type: Object,
+ required: true,
+ },
+ statusBadgeClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ statusIcon: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ blocked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ confidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ taskCompletionStatus: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ authorId() {
+ return getIdFromGraphQLId(`${this.author.id}`);
+ },
+ isAuthorExternal() {
+ return isExternal(this.author.webUrl);
+ },
+ taskStatusString() {
+ const { count, completedCount } = this.taskCompletionStatus;
+
+ return sprintf(
+ n__(
+ '%{completedCount} of %{count} task completed',
+ '%{completedCount} of %{count} tasks completed',
+ count,
+ ),
+ { completedCount, count },
+ );
+ },
+ },
+ mounted() {
+ this.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button');
+ },
+ methods: {
+ handleRightSidebarToggleClick() {
+ if (this.toggleSidebarButtonEl) {
+ this.toggleSidebarButtonEl.dispatchEvent(new Event('click'));
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="detail-page-header">
+ <div class="detail-page-header-body">
+ <div data-testid="status" class="issuable-status-box status-box" :class="statusBadgeClass">
+ <gl-icon v-if="statusIcon" :name="statusIcon" class="d-block d-sm-none" />
+ <span class="d-none d-sm-block"><slot name="status-badge"></slot></span>
+ </div>
+ <div class="issuable-meta gl-display-flex gl-align-items-center d-md-inline-block">
+ <div v-if="blocked || confidential" class="gl-display-inline-block">
+ <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline">
+ <gl-icon name="lock" :aria-label="__('Blocked')" />
+ </div>
+ <div v-if="confidential" data-testid="confidential" class="issuable-warning-icon inline">
+ <gl-icon name="eye-slash" :aria-label="__('Confidential')" />
+ </div>
+ </div>
+ <span>
+ {{ __('Opened') }}
+ <time-ago-tooltip data-testid="startTimeItem" :time="createdAt" />
+ {{ __('by') }}
+ </span>
+ <gl-avatar-link
+ data-testid="avatar"
+ :data-user-id="authorId"
+ :data-username="author.username"
+ :data-name="author.name"
+ :href="author.webUrl"
+ target="_blank"
+ class="js-user-link gl-vertical-align-middle gl-ml-2"
+ >
+ <gl-avatar-labeled
+ :size="24"
+ :src="author.avatarUrl"
+ :label="author.name"
+ class="d-none d-sm-inline-flex gl-mx-1"
+ >
+ <template #meta>
+ <gl-icon v-if="isAuthorExternal" name="external-link" />
+ </template>
+ </gl-avatar-labeled>
+ <strong class="author d-sm-none d-inline">@{{ author.username }}</strong>
+ </gl-avatar-link>
+ <span
+ v-if="taskCompletionStatus"
+ data-testid="task-status"
+ class="gl-display-none gl-md-display-block gl-lg-display-inline-block"
+ >{{ taskStatusString }}</span
+ >
+ </div>
+ <gl-button
+ data-testid="sidebar-toggle"
+ icon="chevron-double-lg-left"
+ class="d-block d-sm-none gutter-toggle issuable-gutter-toggle"
+ :aria-label="__('Expand sidebar')"
+ @click="handleRightSidebarToggleClick"
+ />
+ </div>
+ <div
+ data-testid="header-actions"
+ class="detail-page-header-actions gl-display-flex gl-md-display-block"
+ >
+ <slot name="header-actions"></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue
new file mode 100644
index 00000000000..8849af2a52e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue
@@ -0,0 +1,162 @@
+<script>
+import IssuableSidebar from '~/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue';
+
+import IssuableBody from './issuable_body.vue';
+import IssuableDiscussion from './issuable_discussion.vue';
+import IssuableHeader from './issuable_header.vue';
+
+export default {
+ components: {
+ IssuableSidebar,
+ IssuableHeader,
+ IssuableBody,
+ IssuableDiscussion,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ statusBadgeClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ statusIcon: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ enableEdit: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ enableAutosave: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableZenMode: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableTaskList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ editFormVisible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showFieldTitle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ descriptionPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ descriptionHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ taskCompletionStatus: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ taskListUpdatePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ taskListLockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ },
+ methods: {
+ handleKeydownTitle(e, issuableMeta) {
+ this.$emit('keydown-title', e, issuableMeta);
+ },
+ handleKeydownDescription(e, issuableMeta) {
+ this.$emit('keydown-description', e, issuableMeta);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issuable-show-container" data-qa-selector="issuable_show_container">
+ <issuable-header
+ :status-badge-class="statusBadgeClass"
+ :status-icon="statusIcon"
+ :blocked="issuable.blocked"
+ :confidential="issuable.confidential"
+ :created-at="issuable.createdAt"
+ :author="issuable.author"
+ :task-completion-status="taskCompletionStatus"
+ >
+ <template #status-badge>
+ <slot name="status-badge"></slot>
+ </template>
+ <template #header-actions>
+ <slot name="header-actions"></slot>
+ </template>
+ </issuable-header>
+
+ <issuable-body
+ :issuable="issuable"
+ :status-badge-class="statusBadgeClass"
+ :status-icon="statusIcon"
+ :enable-edit="enableEdit"
+ :enable-autocomplete="enableAutocomplete"
+ :enable-autosave="enableAutosave"
+ :enable-zen-mode="enableZenMode"
+ :enable-task-list="enableTaskList"
+ :edit-form-visible="editFormVisible"
+ :show-field-title="showFieldTitle"
+ :description-preview-path="descriptionPreviewPath"
+ :description-help-path="descriptionHelpPath"
+ :task-list-update-path="taskListUpdatePath"
+ :task-list-lock-version="taskListLockVersion"
+ @edit-issuable="$emit('edit-issuable', $event)"
+ @task-list-update-success="$emit('task-list-update-success', $event)"
+ @task-list-update-failure="$emit('task-list-update-failure')"
+ @keydown-title="handleKeydownTitle"
+ @keydown-description="handleKeydownDescription"
+ >
+ <template #status-badge>
+ <slot name="status-badge"></slot>
+ </template>
+ <template #edit-form-actions="actionsProps">
+ <slot name="edit-form-actions" v-bind="actionsProps"></slot>
+ </template>
+ </issuable-body>
+
+ <issuable-discussion>
+ <template #discussion>
+ <slot name="discussion"></slot>
+ </template>
+ </issuable-discussion>
+
+ <issuable-sidebar>
+ <template #right-sidebar-items="{ sidebarExpanded, toggleSidebar }">
+ <slot name="right-sidebar-items" v-bind="{ sidebarExpanded, toggleSidebar }"></slot>
+ </template>
+ </issuable-sidebar>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
new file mode 100644
index 00000000000..b96ce0c43f7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
@@ -0,0 +1,101 @@
+<script>
+import {
+ GlIcon,
+ GlButton,
+ GlIntersectionObserver,
+ GlTooltipDirective,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ editTitleAndDescription: __('Edit title and description'),
+ },
+ components: {
+ GlIcon,
+ GlButton,
+ GlIntersectionObserver,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ statusBadgeClass: {
+ type: String,
+ required: true,
+ },
+ statusIcon: {
+ type: String,
+ required: true,
+ },
+ enableEdit: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ stickyTitleVisible: false,
+ };
+ },
+ methods: {
+ handleTitleAppear() {
+ this.stickyTitleVisible = false;
+ },
+ handleTitleDisappear() {
+ this.stickyTitleVisible = true;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="title-container">
+ <h2 v-safe-html="issuable.titleHtml || issuable.title" class="title qa-title" dir="auto"></h2>
+ <gl-button
+ v-if="enableEdit"
+ v-gl-tooltip.bottom
+ :title="$options.i18n.editTitleAndDescription"
+ :aria-label="$options.i18n.editTitleAndDescription"
+ icon="pencil"
+ class="btn-edit js-issuable-edit qa-edit-button"
+ @click="$emit('edit-issuable', $event)"
+ />
+ </div>
+ <gl-intersection-observer @appear="handleTitleAppear" @disappear="handleTitleDisappear">
+ <transition name="issuable-header-slide">
+ <div
+ v-if="stickyTitleVisible"
+ class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3"
+ data-testid="header"
+ >
+ <div
+ class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5"
+ >
+ <p
+ data-testid="status"
+ class="issuable-status-box status-box gl-my-0"
+ :class="statusBadgeClass"
+ >
+ <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" />
+ <span class="gl-display-none d-sm-block"><slot name="status-badge"></slot></span>
+ </p>
+ <p
+ class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
+ :title="issuable.title"
+ >
+ {{ issuable.title }}
+ </p>
+ </div>
+ </div>
+ </transition>
+ </gl-intersection-observer>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/constants.js b/app/assets/javascripts/vue_shared/issuable/show/constants.js
new file mode 100644
index 00000000000..346f45c7d90
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/constants.js
@@ -0,0 +1,5 @@
+export const IssuableType = {
+ Issue: 'issue',
+ Incident: 'incident',
+ TestCase: 'test_case',
+};
diff --git a/app/assets/javascripts/vue_shared/issuable/show/event_hub.js b/app/assets/javascripts/vue_shared/issuable/show/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue
new file mode 100644
index 00000000000..99dcccd12ed
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue
@@ -0,0 +1,86 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import Cookies from 'js-cookie';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { USER_COLLAPSED_GUTTER_COOKIE } from '../constants';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ data() {
+ const userExpanded = !parseBoolean(Cookies.get(USER_COLLAPSED_GUTTER_COOKIE));
+
+ // We're deliberately keeping two different props for sidebar status;
+ // 1. userExpanded reflects value based on cookie `collapsed_gutter`.
+ // 2. isExpanded reflect actual sidebar state.
+ return {
+ userExpanded,
+ isExpanded: userExpanded ? bp.isDesktop() : userExpanded,
+ };
+ },
+ mounted() {
+ window.addEventListener('resize', this.handleWindowResize);
+ this.updatePageContainerClass();
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.handleWindowResize);
+ },
+ methods: {
+ updatePageContainerClass() {
+ const layoutPageEl = document.querySelector('.layout-page');
+
+ if (layoutPageEl) {
+ layoutPageEl.classList.toggle('right-sidebar-expanded', this.isExpanded);
+ layoutPageEl.classList.toggle('right-sidebar-collapsed', !this.isExpanded);
+ }
+ },
+ handleWindowResize() {
+ if (this.userExpanded) {
+ this.isExpanded = bp.isDesktop();
+ this.updatePageContainerClass();
+ }
+ },
+ toggleSidebar() {
+ this.isExpanded = !this.isExpanded;
+ this.userExpanded = this.isExpanded;
+
+ Cookies.set(USER_COLLAPSED_GUTTER_COOKIE, !this.userExpanded);
+ this.updatePageContainerClass();
+ },
+ },
+};
+</script>
+
+<template>
+ <aside
+ :class="{ 'right-sidebar-expanded': isExpanded, 'right-sidebar-collapsed': !isExpanded }"
+ class="right-sidebar"
+ aria-live="polite"
+ >
+ <button
+ class="toggle-right-sidebar-button js-toggle-right-sidebar-button w-100 gl-text-decoration-none! gl-display-flex gl-outline-0!"
+ data-testid="toggle-right-sidebar-button"
+ :title="__('Toggle sidebar')"
+ @click="toggleSidebar"
+ >
+ <span v-if="isExpanded" class="collapse-text gl-flex-grow-1 gl-text-left">{{
+ __('Collapse sidebar')
+ }}</span>
+ <gl-icon v-show="isExpanded" data-testid="icon-collapse" name="chevron-double-lg-right" />
+ <gl-icon
+ v-show="!isExpanded"
+ data-testid="icon-expand"
+ name="chevron-double-lg-left"
+ class="gl-ml-2"
+ />
+ </button>
+ <div data-testid="sidebar-items" class="issuable-sidebar">
+ <slot
+ name="right-sidebar-items"
+ v-bind="{ sidebarExpanded: isExpanded, toggleSidebar }"
+ ></slot>
+ </div>
+ </aside>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js b/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js
new file mode 100644
index 00000000000..4f4b6341a1c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js
@@ -0,0 +1 @@
+export const USER_COLLAPSED_GUTTER_COOKIE = 'collapsed_gutter';
diff --git a/app/assets/javascripts/vue_shared/mixins/issuable.js b/app/assets/javascripts/vue_shared/mixins/issuable.js
deleted file mode 100644
index fab0919d96e..00000000000
--- a/app/assets/javascripts/vue_shared/mixins/issuable.js
+++ /dev/null
@@ -1,14 +0,0 @@
-export default {
- props: {
- issuableType: {
- required: true,
- type: String,
- },
- },
-
- computed: {
- issuableDisplayName() {
- return this.issuableType.replace(/_/g, ' ');
- },
- },
-};
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
deleted file mode 100644
index 4a6edae0c06..00000000000
--- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
+++ /dev/null
@@ -1,214 +0,0 @@
-import { isEmpty } from 'lodash';
-import { formatDate } from '~/lib/utils/datetime_utility';
-import { sprintf, __ } from '~/locale';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
-
-const mixins = {
- data() {
- return {
- removeDisabled: false,
- };
- },
- props: {
- idKey: {
- type: Number,
- required: true,
- },
- displayReference: {
- type: String,
- required: true,
- },
- pathIdSeparator: {
- type: String,
- required: true,
- },
- eventNamespace: {
- type: String,
- required: false,
- default: '',
- },
- confidential: {
- type: Boolean,
- required: false,
- default: false,
- },
- title: {
- type: String,
- required: false,
- default: '',
- },
- path: {
- type: String,
- required: false,
- default: '',
- },
- state: {
- type: String,
- required: false,
- default: '',
- },
- createdAt: {
- type: String,
- required: false,
- default: '',
- },
- closedAt: {
- type: String,
- required: false,
- default: '',
- },
- mergedAt: {
- type: String,
- required: false,
- default: '',
- },
- milestone: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- dueDate: {
- type: String,
- required: false,
- default: '',
- },
- assignees: {
- type: Array,
- required: false,
- default: () => [],
- },
- weight: {
- type: Number,
- required: false,
- default: 0,
- },
- canRemove: {
- type: Boolean,
- required: false,
- default: false,
- },
- isMergeRequest: {
- type: Boolean,
- required: false,
- default: false,
- },
- pipelineStatus: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- },
- mixins: [timeagoMixin],
- computed: {
- hasState() {
- return this.state && this.state.length > 0;
- },
- hasPipeline() {
- return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length;
- },
- isOpen() {
- return this.state === 'opened' || this.state === 'reopened';
- },
- isClosed() {
- return this.state === 'closed';
- },
- isMerged() {
- return this.state === 'merged';
- },
- hasTitle() {
- return this.title.length > 0;
- },
- hasAssignees() {
- return this.assignees.length > 0;
- },
- hasMilestone() {
- return !isEmpty(this.milestone);
- },
- iconName() {
- if (this.isMergeRequest && this.isMerged) {
- return 'merge';
- }
-
- return this.isOpen ? 'issue-open-m' : 'issue-close';
- },
- iconClass() {
- if (this.isMergeRequest && this.isClosed) {
- return 'merge-request-status closed issue-token-state-icon-closed';
- }
-
- return this.isOpen
- ? 'issue-token-state-icon-open gl-text-green-500'
- : 'issue-token-state-icon-closed gl-text-blue-500';
- },
- computedLinkElementType() {
- return this.path.length > 0 ? 'a' : 'span';
- },
- computedPath() {
- return this.path.length ? this.path : null;
- },
- itemPath() {
- return this.displayReference.split(this.pathIdSeparator)[0];
- },
- itemId() {
- return this.displayReference.split(this.pathIdSeparator).pop();
- },
- createdAtInWords() {
- return this.createdAt ? this.timeFormatted(this.createdAt) : '';
- },
- createdAtTimestamp() {
- return this.createdAt ? formatDate(new Date(this.createdAt)) : '';
- },
- mergedAtTimestamp() {
- return this.mergedAt ? formatDate(new Date(this.mergedAt)) : '';
- },
- mergedAtInWords() {
- return this.mergedAt ? this.timeFormatted(this.mergedAt) : '';
- },
- closedAtInWords() {
- return this.closedAt ? this.timeFormatted(this.closedAt) : '';
- },
- closedAtTimestamp() {
- return this.closedAt ? formatDate(new Date(this.closedAt)) : '';
- },
- stateText() {
- if (this.isMerged) {
- return __('Merged');
- }
-
- return this.isOpen ? __('Created') : __('Closed');
- },
- stateTimeInWords() {
- if (this.isMerged) {
- return this.mergedAtInWords;
- }
-
- return this.isOpen ? this.createdAtInWords : this.closedAtInWords;
- },
- stateTimestamp() {
- if (this.isMerged) {
- return this.mergedAtTimestamp;
- }
-
- return this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp;
- },
- pipelineStatusTooltip() {
- return this.hasPipeline
- ? sprintf(__('Pipeline: %{status}'), { status: this.pipelineStatus.label })
- : '';
- },
- },
- methods: {
- onRemoveRequest() {
- let namespacePrefix = '';
- if (this.eventNamespace && this.eventNamespace.length > 0) {
- namespacePrefix = `${this.eventNamespace}`;
- }
-
- this.$emit(`${namespacePrefix}RemoveRequest`, this.idKey);
-
- this.removeDisabled = true;
- },
- },
-};
-
-export default mixins;
diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
index 42272c222fc..d1630c9ac13 100644
--- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
+++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
@@ -85,7 +85,7 @@ export default {
);
},
i18n: {
- buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'),
+ buttonLabel: s__('SecurityConfiguration|Configure with a merge request'),
noSuccessPathError: s__(
'SecurityConfiguration|%{featureName} merge request creation mutation failed',
),
diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql
index ae77a2ce5e4..829b9d9f9d8 100644
--- a/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql
@@ -1,6 +1,8 @@
fragment JobArtifacts on Pipeline {
+ id
jobs(securityReportTypes: $reportTypes) {
nodes {
+ id
name
artifacts {
nodes {
diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql
index 4ce13827da2..2e80db30e9a 100644
--- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql
@@ -4,11 +4,14 @@ query securityReportDownloadPaths(
$reportTypes: [SecurityReportTypeEnum!]
) {
project(fullPath: $projectPath) {
+ id
mergeRequest(iid: $iid) {
+ id
headPipeline {
id
jobs(securityReportTypes: $reportTypes) {
nodes {
+ id
name
artifacts {
nodes {
diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
index e1f3c55a886..e4f0c392b91 100644
--- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
@@ -2,8 +2,8 @@
query getPipelineCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) {
project(fullPath: $projectPath) {
+ id
pipeline(iid: $iid) {
- id
...JobArtifacts
}
}