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