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