diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared')
47 files changed, 832 insertions, 392 deletions
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue index f2ea55df63d..96c2ffa929c 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue @@ -145,11 +145,14 @@ export default { }, currentTabIndex: { get() { - return this.$options.tabsConfig.findIndex((tab) => tab.id === this.activeTab); + const tabIndex = this.$options.tabsConfig.findIndex((tab) => tab.id === this.activeTab); + return tabIndex >= 0 ? tabIndex : 0; }, set(tabIdx) { const tabId = this.$options.tabsConfig[tabIdx].id; - this.$router.replace({ name: 'tab', params: { tabId } }); + if (this.$route.params?.tabId !== tabId) { + this.$router.push({ name: 'tab', params: { tabId } }); + } }, }, environmentName() { diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js index 5793069440c..357dfa49901 100644 --- a/app/assets/javascripts/vue_shared/alert_details/index.js +++ b/app/assets/javascripts/vue_shared/alert_details/index.js @@ -15,9 +15,17 @@ Vue.use(VueApollo); export default (selector) => { const domEl = document.querySelector(selector); - const { alertId, projectPath, projectIssuesPath, projectId, page, canUpdate } = domEl.dataset; + const { + alertId, + projectPath, + projectIssuesPath, + projectAlertManagementDetailsPath, + projectId, + page, + canUpdate, + } = domEl.dataset; const iid = alertId; - const router = createRouter(); + const router = createRouter(projectAlertManagementDetailsPath); const resolvers = { Mutation: { diff --git a/app/assets/javascripts/vue_shared/alert_details/router.js b/app/assets/javascripts/vue_shared/alert_details/router.js index 5687fe4e0f5..26477a3a66a 100644 --- a/app/assets/javascripts/vue_shared/alert_details/router.js +++ b/app/assets/javascripts/vue_shared/alert_details/router.js @@ -5,9 +5,26 @@ import { joinPaths } from '~/lib/utils/url_utility'; Vue.use(VueRouter); export default function createRouter(base) { - return new VueRouter({ - mode: 'hash', + const router = new VueRouter({ + mode: 'history', base: joinPaths(gon.relative_url_root || '', base), routes: [{ path: '/:tabId', name: 'tab' }], }); + + /* + Backward-compatible behavior. Redirects hash mode URLs to history mode ones. + Ex: from #/overview to /overview + from #/metrics to /metrics + from #/activity to /activity + */ + router.beforeEach((to, _, next) => { + if (to.hash.startsWith('#/')) { + const path = to.fullPath.substring(2); + next(path); + } else { + next(); + } + }); + + return router; } diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue index dc4d1bd56e9..ed0eb9cc0b8 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue @@ -15,8 +15,14 @@ export default { mounted() { handleBlobRichViewer(this.$refs.content, this.type); }, + safeHtmlConfig: { + ADD_TAGS: ['copy-code'], + }, }; </script> <template> - <markdown-field-view ref="content" v-safe-html="richViewer || content" /> + <markdown-field-view + ref="content" + v-safe-html:[$options.safeHtmlConfig]="richViewer || content" + /> </template> diff --git a/app/assets/javascripts/vue_shared/components/code_block.stories.js b/app/assets/javascripts/vue_shared/components/code_block.stories.js index e02a346c1de..994913dc1a8 100644 --- a/app/assets/javascripts/vue_shared/components/code_block.stories.js +++ b/app/assets/javascripts/vue_shared/components/code_block.stories.js @@ -13,6 +13,5 @@ const Template = (args, { argTypes }) => ({ export const Default = Template.bind({}); Default.args = { - // eslint-disable-next-line @gitlab/require-i18n-strings code: `git commit -a "Message"\ngit push`, }; diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue index 5b9efff1c06..2bdc8a174d0 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue @@ -36,6 +36,11 @@ export default { required: false, default: 'confirm-danger-button', }, + buttonQaSelector: { + type: String, + required: false, + default: null, + }, buttonVariant: { type: String, required: false, @@ -53,7 +58,7 @@ export default { :variant="buttonVariant" :disabled="disabled" :data-testid="buttonTestid" - data-qa-selector="confirm_danger_button" + :data-qa-selector="buttonQaSelector" >{{ buttonText }}</gl-button > <confirm-danger-modal diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js index 7ecc309db52..b56434f746e 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js @@ -1,4 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import ConfirmDanger from './confirm_danger.vue'; export default { diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js index 8256d953466..a48b8bcfa8e 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js @@ -1,5 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ - import { __ } from '~/locale'; import DropdownWidget from './dropdown_widget.vue'; diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 2227047a909..8a3a174f414 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -45,7 +45,7 @@ export default { }, levelIndentation() { return { - marginLeft: this.level ? `${this.level * 16}px` : null, + marginLeft: this.level ? `${this.level * 8}px` : null, }; }, fileClass() { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 4873996d357..755ce004aa9 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -13,11 +13,19 @@ export const FILTER_NONE_ANY = [FILTER_NONE, FILTER_ANY]; export const OPERATOR_IS = '='; export const OPERATOR_IS_TEXT = __('is'); export const OPERATOR_IS_NOT = '!='; -export const OPERATOR_IS_NOT_TEXT = __('is not'); +export const OPERATOR_IS_NOT_TEXT = __('is not one of'); +export const OPERATOR_OR = '||'; +export const OPERATOR_OR_TEXT = __('is one of'); export const OPERATOR_IS_ONLY = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }]; export const OPERATOR_IS_NOT_ONLY = [{ value: OPERATOR_IS_NOT, description: OPERATOR_IS_NOT_TEXT }]; +export const OPERATOR_OR_ONLY = [{ value: OPERATOR_OR, description: OPERATOR_OR_TEXT }]; export const OPERATOR_IS_AND_IS_NOT = [...OPERATOR_IS_ONLY, ...OPERATOR_IS_NOT_ONLY]; +export const OPERATOR_IS_NOT_OR = [ + ...OPERATOR_IS_ONLY, + ...OPERATOR_IS_NOT_ONLY, + ...OPERATOR_OR_ONLY, +]; export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title: __('None') }; export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') }; @@ -55,10 +63,26 @@ export const TOKEN_TITLE_MILESTONE = __('Milestone'); export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); export const TOKEN_TITLE_ORGANIZATION = s__('Crm|Organization'); export const TOKEN_TITLE_RELEASE = __('Release'); +export const TOKEN_TITLE_SOURCE_BRANCH = __('Source Branch'); +export const TOKEN_TITLE_STATUS = __('Status'); +export const TOKEN_TITLE_TARGET_BRANCH = __('Target Branch'); export const TOKEN_TITLE_TYPE = __('Type'); +export const TOKEN_TYPE_ASSIGNEE = 'assignee'; +export const TOKEN_TYPE_AUTHOR = 'author'; +export const TOKEN_TYPE_CONFIDENTIAL = 'confidential'; +export const TOKEN_TYPE_CONTACT = 'contact'; +export const TOKEN_TYPE_EPIC = 'epic'; // As health status gets reused between issue lists and boards // this is in the shared constants. Until we have not decoupled the EE filtered search bar // from the CE component, we need to keep this in the CE code. // https://gitlab.com/gitlab-org/gitlab/-/issues/377838 -export const TOKEN_TYPE_HEALTH = 'health_status'; +export const TOKEN_TYPE_HEALTH = 'health'; +export const TOKEN_TYPE_ITERATION = 'iteration'; +export const TOKEN_TYPE_LABEL = 'label'; +export const TOKEN_TYPE_MILESTONE = 'milestone'; +export const TOKEN_TYPE_MY_REACTION = 'my-reaction'; +export const TOKEN_TYPE_ORGANIZATION = 'organization'; +export const TOKEN_TYPE_RELEASE = 'release'; +export const TOKEN_TYPE_TYPE = 'type'; +export const TOKEN_TYPE_WEIGHT = 'weight'; 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 8821084ef35..0d0787e7033 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 @@ -89,6 +89,11 @@ export default { required: false, default: () => ({}), }, + showFriendlyText: { + type: Boolean, + required: false, + default: false, + }, syncFilterAndSort: { type: Boolean, required: false, @@ -319,7 +324,7 @@ export default { (sortBy) => sortBy.sortDirection.ascending === sort || sortBy.sortDirection.descending === sort, ); - this.selectedSortDirection = Object.keys(this.selectedSortOption.sortDirection).find( + this.selectedSortDirection = Object.keys(this.selectedSortOption?.sortDirection || {}).find( (key) => this.selectedSortOption.sortDirection[key] === sort, ); }, @@ -351,6 +356,7 @@ export default { :close-button-title="__('Close')" :clear-recent-searches-text="__('Clear recent searches')" :no-recent-searches-text="__(`You don't have any recent searches`)" + :show-friendly-text="showFriendlyText" class="flex-grow-1" @history-item-selected="handleHistoryItemSelected" @clear="onClear" diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue index 482a2964b4c..2f10e068542 100644 --- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue @@ -129,6 +129,8 @@ export default { v-gl-tooltip.hover="toggleVisibilityLabel" :aria-label="toggleVisibilityLabel" :icon="toggleVisibilityIcon" + data-testid="toggle-visibility-button" + data-qa-selector="toggle_visibility_button" @click.stop="handleToggleVisibilityButtonClick" /> <clipboard-button diff --git a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue deleted file mode 100644 index c2be5e4f7a1..00000000000 --- a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue +++ /dev/null @@ -1,89 +0,0 @@ -<script> -import { GlBadge } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import Tracking from '~/tracking'; -import axios from '~/lib/utils/axios_utils'; -import { joinPaths } from '~/lib/utils/url_utility'; -import { helpPagePath } from '~/helpers/help_page_helper'; - -const STATUS_TYPES = { - SUCCESS: 'success', - WARNING: 'warning', - DANGER: 'danger', -}; - -const UPGRADE_DOCS_URL = helpPagePath('update/index'); - -export default { - name: 'GitlabVersionCheck', - components: { - GlBadge, - }, - mixins: [Tracking.mixin()], - props: { - size: { - type: String, - required: false, - default: 'md', - }, - }, - data() { - return { - status: null, - }; - }, - computed: { - title() { - if (this.status === STATUS_TYPES.SUCCESS) { - return s__('VersionCheck|Up to date'); - } else if (this.status === STATUS_TYPES.WARNING) { - return s__('VersionCheck|Update available'); - } else if (this.status === STATUS_TYPES.DANGER) { - return s__('VersionCheck|Update ASAP'); - } - - return null; - }, - }, - created() { - this.checkGitlabVersion(); - }, - methods: { - checkGitlabVersion() { - axios - .get(joinPaths('/', gon.relative_url_root, '/admin/version_check.json')) - .then((res) => { - if (res.data) { - this.status = res.data.severity; - - this.track('rendered_version_badge', { - label: this.title, - }); - } - }) - .catch(() => { - // Silently fail - this.status = null; - }); - }, - onClick() { - this.track('click_version_badge', { label: this.title }); - }, - }, - UPGRADE_DOCS_URL, -}; -</script> - -<template> - <!-- TODO: remove the span element once bootstrap-vue is updated to version 2.21.1 --> - <!-- TODO: https://github.com/bootstrap-vue/bootstrap-vue/issues/6219 --> - <span v-if="status" data-testid="badge-click-wrapper" @click="onClick"> - <gl-badge - :href="$options.UPGRADE_DOCS_URL" - class="version-check-badge" - :variant="status" - :size="size" - >{{ title }}</gl-badge - > - </span> -</template> diff --git a/app/assets/javascripts/vue_shared/components/group_select/constants.js b/app/assets/javascripts/vue_shared/components/group_select/constants.js new file mode 100644 index 00000000000..bc70936eb36 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/group_select/constants.js @@ -0,0 +1,6 @@ +import { __ } from '~/locale'; + +export const TOGGLE_TEXT = __('Search for a group'); +export const FETCH_GROUPS_ERROR = __('Unable to fetch groups. Reload the page to try again.'); +export const FETCH_GROUP_ERROR = __('Unable to fetch group. Reload the page to try again.'); +export const QUERY_TOO_SHORT_MESSAGE = __('Enter at least three characters to search.'); diff --git a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue b/app/assets/javascripts/vue_shared/components/group_select/group_select.vue new file mode 100644 index 00000000000..1de6c0121bc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/group_select/group_select.vue @@ -0,0 +1,195 @@ +<script> +import { debounce } from 'lodash'; +import { GlListbox } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import Api from '~/api'; +import { __ } from '~/locale'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { createAlert } from '~/flash'; +import { groupsPath } from './utils'; +import { + TOGGLE_TEXT, + FETCH_GROUPS_ERROR, + FETCH_GROUP_ERROR, + QUERY_TOO_SHORT_MESSAGE, +} from './constants'; + +const MINIMUM_QUERY_LENGTH = 3; + +export default { + components: { + GlListbox, + }, + props: { + inputName: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + initialSelection: { + type: String, + required: false, + default: null, + }, + clearable: { + type: Boolean, + required: false, + default: false, + }, + parentGroupID: { + type: String, + required: false, + default: null, + }, + groupsFilter: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + pristine: true, + searching: false, + searchString: '', + groups: [], + selectedValue: null, + selectedText: null, + }; + }, + computed: { + selected: { + set(value) { + this.selectedValue = value; + this.selectedText = + value === null ? null : this.groups.find((group) => group.value === value).full_name; + }, + get() { + return this.selectedValue; + }, + }, + toggleText() { + return this.selectedText ?? this.$options.i18n.toggleText; + }, + inputValue() { + return this.selectedValue ? this.selectedValue : ''; + }, + isSearchQueryTooShort() { + return this.searchString && this.searchString.length < MINIMUM_QUERY_LENGTH; + }, + noResultsText() { + return this.isSearchQueryTooShort + ? this.$options.i18n.searchQueryTooShort + : this.$options.i18n.noResultsText; + }, + }, + created() { + this.fetchInitialSelection(); + }, + methods: { + search: debounce(function debouncedSearch(searchString) { + this.searchString = searchString; + if (this.isSearchQueryTooShort) { + this.groups = []; + } else { + this.fetchGroups(searchString); + } + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), + async fetchGroups(searchString = '') { + this.searching = true; + + try { + const { data } = await axios.get( + Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)), + { + params: { + search: searchString, + }, + }, + ); + const groups = data.length ? data : data.results || []; + + this.groups = groups.map((group) => ({ + ...group, + value: String(group.id), + })); + + this.searching = false; + } catch (error) { + createAlert({ + message: FETCH_GROUPS_ERROR, + error, + parent: this.$el, + }); + } + }, + async fetchInitialSelection() { + if (!this.initialSelection) { + this.pristine = false; + return; + } + this.searching = true; + try { + const group = await Api.group(this.initialSelection); + this.selectedValue = this.initialSelection; + this.selectedText = group.full_name; + this.pristine = false; + this.searching = false; + } catch (error) { + createAlert({ + message: FETCH_GROUP_ERROR, + error, + parent: this.$el, + }); + } + }, + onShown() { + if (!this.searchString && !this.groups.length) { + this.fetchGroups(); + } + }, + onReset() { + this.selected = null; + }, + }, + i18n: { + toggleText: TOGGLE_TEXT, + selectGroup: __('Select a group'), + reset: __('Reset'), + noResultsText: __('No results found.'), + searchQueryTooShort: QUERY_TOO_SHORT_MESSAGE, + }, +}; +</script> + +<template> + <div> + <gl-listbox + ref="listbox" + v-model="selected" + :header-text="$options.i18n.selectGroup" + :reset-button-label="$options.i18n.reset" + :toggle-text="toggleText" + :loading="searching && pristine" + :searching="searching" + :items="groups" + :no-results-text="noResultsText" + searchable + @shown="onShown" + @search="search" + @reset="onReset" + > + <template #list-item="{ item }"> + <div class="gl-font-weight-bold"> + {{ item.full_name }} + </div> + <div class="gl-text-gray-300">{{ item.full_path }}</div> + </template> + </gl-listbox> + <div class="flash-container"></div> + <input :id="inputId" data-testid="input" type="hidden" :name="inputName" :value="inputValue" /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue index 1b89bd324c6..f349aa78bac 100644 --- a/app/assets/javascripts/vue_shared/components/help_popover.vue +++ b/app/assets/javascripts/vue_shared/components/help_popover.vue @@ -20,6 +20,11 @@ export default { required: false, default: () => ({}), }, + icon: { + type: String, + required: false, + default: 'question-o', + }, }, methods: { targetFn() { @@ -30,7 +35,7 @@ export default { </script> <template> <span> - <gl-button ref="popoverTrigger" variant="link" icon="question-o" :aria-label="__('Help')" /> + <gl-button ref="popoverTrigger" variant="link" :icon="icon" :aria-label="__('Help')" /> <gl-popover :target="targetFn" v-bind="options"> <template v-if="options.title" #title> <span v-safe-html="options.title"></span> diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index b38772d5aa5..c0712e46613 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -72,7 +72,7 @@ export default { required: false, default: '', }, - initOnAutofocus: { + autofocus: { type: Boolean, required: false, default: false, @@ -87,20 +87,20 @@ export default { return { editingMode: EDITING_MODE_MARKDOWN_FIELD, switchEditingControlEnabled: true, - autofocus: this.initOnAutofocus, + autofocused: false, }; }, computed: { isContentEditorActive() { return this.enableContentEditor && this.editingMode === EDITING_MODE_CONTENT_EDITOR; }, - contentEditorAutofocus() { + contentEditorAutofocused() { // Match textarea focus behavior - return this.autofocus ? 'end' : false; + return this.autofocus && !this.autofocused ? 'end' : false; }, }, mounted() { - this.autofocusTextarea(this.editingMode); + this.autofocusTextarea(); }, methods: { updateMarkdownFromContentEditor({ markdown }) { @@ -120,7 +120,6 @@ export default { }, onEditingModeChange(editingMode) { this.notifyEditingModeChange(editingMode); - this.enableAutofocus(editingMode); }, onEditingModeRestored(editingMode) { this.notifyEditingModeChange(editingMode); @@ -128,15 +127,15 @@ export default { notifyEditingModeChange(editingMode) { this.$emit(editingMode); }, - enableAutofocus(editingMode) { - this.autofocus = true; - this.autofocusTextarea(editingMode); - }, - autofocusTextarea(editingMode) { - if (this.autofocus && editingMode === EDITING_MODE_MARKDOWN_FIELD) { + autofocusTextarea() { + if (this.autofocus && this.editingMode === EDITING_MODE_MARKDOWN_FIELD) { this.$refs.textarea.focus(); + this.setEditorAsAutofocused(); } }, + setEditorAsAutofocused() { + this.autofocused = true; + }, }, switchEditingControlOptions: [ { text: __('Source'), value: EDITING_MODE_MARKDOWN_FIELD }, @@ -197,7 +196,8 @@ export default { :render-markdown="renderMarkdown" :uploads-path="uploadsPath" :markdown="value" - :autofocus="contentEditorAutofocus" + :autofocus="contentEditorAutofocused" + @initialized="setEditorAsAutofocused" @change="updateMarkdownFromContentEditor" @loading="disableSwitchEditingControl" @loadingSuccess="enableSwitchEditingControl" diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js b/app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js new file mode 100644 index 00000000000..03bd64e2a57 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js @@ -0,0 +1,54 @@ +import { GlButton } from '@gitlab/ui'; +import { MOCK_HTML } from '../../../../../../spec/frontend/vue_shared/components/markdown_drawer/mock_data'; +import MarkdownDrawer from './markdown_drawer.vue'; + +export default { + component: MarkdownDrawer, + title: 'vue_shared/markdown_drawer', + parameters: { + mirage: { + timing: 1000, + handlers: { + get: { + '/help/user/search/global_search/advanced_search_syntax.json': [ + 200, + {}, + { html: MOCK_HTML }, + ], + }, + }, + }, + }, +}; + +const createStory = ({ ...options }) => (_, { argTypes }) => ({ + components: { MarkdownDrawer, GlButton }, + props: Object.keys(argTypes), + data() { + return { + render: false, + }; + }, + methods: { + toggleDrawer() { + this.$refs.drawer.toggleDrawer(); + }, + }, + mounted() { + window.requestAnimationFrame(() => { + this.render = true; + }); + }, + template: ` + <div v-if="render"> + <gl-button @click="toggleDrawer">Open Drawer</gl-button> + <markdown-drawer + :documentPath="'user/search/global_search/advanced_search_syntax.json'" + ref="drawer" + /> + </div> + `, + ...options, +}); + +export const Default = createStory({}); diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue new file mode 100644 index 00000000000..a4b509f8656 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue @@ -0,0 +1,117 @@ +<script> +import { GlSafeHtmlDirective as SafeHtml, GlDrawer, GlAlert, GlSkeletonLoader } from '@gitlab/ui'; +import $ from 'jquery'; +import '~/behaviors/markdown/render_gfm'; +import { s__ } from '~/locale'; +import { contentTop } from '~/lib/utils/common_utils'; +import { getRenderedMarkdown } from './utils/fetch'; + +export const cache = {}; + +export default { + name: 'MarkdownDrawer', + components: { + GlDrawer, + GlAlert, + GlSkeletonLoader, + }, + directives: { + SafeHtml, + }, + i18n: { + alert: s__('MardownDrawer|Could not fetch help contents.'), + }, + props: { + documentPath: { + type: String, + required: true, + }, + }, + data() { + return { + loading: false, + hasFetchError: false, + title: '', + body: null, + open: false, + }; + }, + computed: { + drawerOffsetTop() { + return `${contentTop()}px`; + }, + }, + watch: { + documentPath: { + immediate: true, + handler: 'fetchMarkdown', + }, + open(open) { + if (open && this.body) { + this.renderGLFM(); + } + }, + }, + methods: { + async fetchMarkdown() { + const cached = cache[this.documentPath]; + this.hasFetchError = false; + this.title = ''; + if (cached) { + this.title = cached.title; + this.body = cached.body; + if (this.open) { + this.renderGLFM(); + } + } else { + this.loading = true; + const { body, title, hasFetchError } = await getRenderedMarkdown(this.documentPath); + this.title = title; + this.body = body; + this.loading = false; + this.hasFetchError = hasFetchError; + if (this.open) { + this.renderGLFM(); + } + cache[this.documentPath] = { title, body }; + } + }, + renderGLFM() { + this.$nextTick(() => { + $(this.$refs['content-element']).renderGFM(); + }); + }, + closeDrawer() { + this.open = false; + }, + toggleDrawer() { + this.open = !this.open; + }, + openDrawer() { + this.open = true; + }, + }, + safeHtmlConfig: { + ADD_TAGS: ['copy-code'], + }, +}; +</script> +<template> + <gl-drawer :header-height="drawerOffsetTop" :open="open" header-sticky @close="closeDrawer"> + <template #title> + <h4 data-testid="title-element" class="gl-m-0">{{ title }}</h4> + </template> + <template #default> + <div v-if="hasFetchError"> + <gl-alert :dismissible="false" variant="danger">{{ $options.i18n.alert }}</gl-alert> + </div> + <gl-skeleton-loader v-else-if="loading" /> + <div + v-else + ref="content-element" + v-safe-html:[$options.safeHtmlConfig]="body" + class="md" + ></div> + </template> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js b/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js new file mode 100644 index 00000000000..7c8e1bc160a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/browser'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import axios from '~/lib/utils/axios_utils'; + +export const splitDocument = (htmlString) => { + const htmlDocument = new DOMParser().parseFromString(htmlString, 'text/html'); + const title = htmlDocument.querySelector('h1')?.innerText; + htmlDocument.querySelector('h1')?.remove(); + return { + title, + body: htmlDocument.querySelector('body').innerHTML.toString(), + }; +}; + +export const getRenderedMarkdown = (documentPath) => { + return axios + .get(helpPagePath(documentPath)) + .then(({ data }) => { + const { body, title } = splitDocument(data.html); + return { + body, + title, + hasFetchError: false, + }; + }) + .catch((e) => { + Sentry.captureException(e); + return { + hasFetchError: true, + }; + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select_deprecated.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select_deprecated.vue deleted file mode 100644 index ba9edc7620a..00000000000 --- a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select_deprecated.vue +++ /dev/null @@ -1,212 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownDivider, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, - GlIntersectionObserver, - GlLoadingIcon, -} from '@gitlab/ui'; -import { __ } from '~/locale'; - -export const EMPTY_NAMESPACE_ID = -1; -export const i18n = { - DEFAULT_TEXT: __('Select a new namespace'), - DEFAULT_EMPTY_NAMESPACE_TEXT: __('No namespace'), - GROUPS: __('Groups'), - USERS: __('Users'), -}; - -const filterByName = (data, searchTerm = '') => { - if (!searchTerm) { - return data; - } - - return data.filter((d) => d.humanName.toLowerCase().includes(searchTerm.toLowerCase())); -}; - -export default { - name: 'NamespaceSelectDeprecated', - components: { - GlDropdown, - GlDropdownDivider, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, - GlIntersectionObserver, - GlLoadingIcon, - }, - props: { - groupNamespaces: { - type: Array, - required: false, - default: () => [], - }, - userNamespaces: { - type: Array, - required: false, - default: () => [], - }, - fullWidth: { - type: Boolean, - required: false, - default: false, - }, - defaultText: { - type: String, - required: false, - default: i18n.DEFAULT_TEXT, - }, - includeHeaders: { - type: Boolean, - required: false, - default: true, - }, - emptyNamespaceTitle: { - type: String, - required: false, - default: i18n.DEFAULT_EMPTY_NAMESPACE_TEXT, - }, - includeEmptyNamespace: { - type: Boolean, - required: false, - default: false, - }, - hasNextPageOfGroups: { - type: Boolean, - required: false, - default: false, - }, - isLoading: { - type: Boolean, - required: false, - default: false, - }, - isSearchLoading: { - type: Boolean, - required: false, - default: false, - }, - shouldFilterNamespaces: { - type: Boolean, - required: false, - default: true, - }, - }, - data() { - return { - searchTerm: '', - selectedNamespace: null, - }; - }, - computed: { - hasUserNamespaces() { - return this.userNamespaces.length; - }, - hasGroupNamespaces() { - return this.groupNamespaces.length; - }, - filteredGroupNamespaces() { - if (!this.shouldFilterNamespaces) return this.groupNamespaces; - if (!this.hasGroupNamespaces) return []; - return filterByName(this.groupNamespaces, this.searchTerm); - }, - filteredUserNamespaces() { - if (!this.shouldFilterNamespaces) return this.userNamespaces; - if (!this.hasUserNamespaces) return []; - return filterByName(this.userNamespaces, this.searchTerm); - }, - selectedNamespaceText() { - return this.selectedNamespace?.humanName || this.defaultText; - }, - filteredEmptyNamespaceTitle() { - const { includeEmptyNamespace, emptyNamespaceTitle, searchTerm } = this; - - if (!includeEmptyNamespace) { - return ''; - } - if (!searchTerm) { - return emptyNamespaceTitle; - } - - return emptyNamespaceTitle.toLowerCase().includes(searchTerm.toLowerCase()); - }, - }, - watch: { - searchTerm() { - this.$emit('search', this.searchTerm); - }, - }, - methods: { - handleSelect(item) { - this.selectedNamespace = item; - this.searchTerm = ''; - this.$emit('select', item); - }, - handleSelectEmptyNamespace() { - this.handleSelect({ id: EMPTY_NAMESPACE_ID, humanName: this.emptyNamespaceTitle }); - }, - }, - i18n, -}; -</script> -<template> - <gl-dropdown - :text="selectedNamespaceText" - :block="fullWidth" - data-qa-selector="namespaces_list" - @show="$emit('show')" - > - <template #header> - <gl-search-box-by-type - v-model.trim="searchTerm" - :is-loading="isSearchLoading" - data-qa-selector="namespaces_list_search" - /> - </template> - <div v-if="filteredEmptyNamespaceTitle"> - <gl-dropdown-item - data-qa-selector="namespaces_list_item" - @click="handleSelectEmptyNamespace()" - > - {{ emptyNamespaceTitle }} - </gl-dropdown-item> - <gl-dropdown-divider /> - </div> - <div - v-if="hasUserNamespaces" - data-qa-selector="namespaces_list_users" - data-testid="namespace-list-users" - > - <gl-dropdown-section-header v-if="includeHeaders">{{ - $options.i18n.USERS - }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="item in filteredUserNamespaces" - :key="item.id" - data-qa-selector="namespaces_list_item" - @click="handleSelect(item)" - >{{ item.humanName }}</gl-dropdown-item - > - </div> - <div - v-if="hasGroupNamespaces" - data-qa-selector="namespaces_list_groups" - data-testid="namespace-list-groups" - > - <gl-dropdown-section-header v-if="includeHeaders">{{ - $options.i18n.GROUPS - }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="item in filteredGroupNamespaces" - :key="item.id" - data-qa-selector="namespaces_list_item" - @click="handleSelect(item)" - >{{ item.humanName }}</gl-dropdown-item - > - </div> - <gl-loading-icon v-if="isLoading" class="gl-mb-3" size="sm" /> - <gl-intersection-observer v-if="hasNextPageOfGroups" @appear="$emit('load-more-groups')" /> - </gl-dropdown> -</template> 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 a5027d2ca5c..867222279b2 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 @@ -9,9 +9,12 @@ import { } from '@gitlab/ui'; import Api from '~/api'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; import Tracking from '~/tracking'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + OPERATOR_IS_ONLY, + TOKEN_TITLE_ASSIGNEE, + TOKEN_TITLE_AUTHOR, +} from '~/vue_shared/components/filtered_search_bar/constants'; 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'; import { initialPaginationState, defaultI18n, defaultPageSize } from './constants'; @@ -112,7 +115,7 @@ export default { { type: 'author_username', icon: 'user', - title: __('Author'), + title: TOKEN_TITLE_AUTHOR, unique: true, symbol: '@', token: AuthorToken, @@ -123,7 +126,7 @@ export default { { type: 'assignee_username', icon: 'user', - title: __('Assignee'), + title: TOKEN_TITLE_ASSIGNEE, unique: true, symbol: '@', token: AuthorToken, diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js index f16afc77164..fd9d69bae22 100644 --- a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js +++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js @@ -1,4 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import PaginationBar from './pagination_bar.vue'; export default { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js deleted file mode 100644 index 1c08433ee78..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js +++ /dev/null @@ -1 +0,0 @@ -// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports. diff --git a/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js deleted file mode 100644 index 1c08433ee78..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js +++ /dev/null @@ -1 +0,0 @@ -// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports. 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 index 0f5560ff628..02323e5a0c6 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue @@ -43,6 +43,11 @@ export default { required: false, default: false, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -128,7 +133,7 @@ export default { </script> <template> - <div class="block js-issuable-move-block issuable-move-dropdown sidebar-move-issue-dropdown"> + <div class="js-issuable-move-block issuable-move-dropdown sidebar-move-issue-dropdown"> <div v-gl-tooltip.left.viewport data-testid="move-collapsed" @@ -141,7 +146,7 @@ export default { <gl-dropdown ref="dropdown" :block="true" - :disabled="moveInProgress" + :disabled="moveInProgress || disabled" class="hide-collapsed" toggle-class="js-sidebar-dropdown-toggle" @shown="fetchProjects" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js deleted file mode 100644 index 1c08433ee78..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js +++ /dev/null @@ -1 +0,0 @@ -// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports. diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue index 0127df730b8..27186281c42 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue @@ -194,6 +194,7 @@ export default { ref="dropdown" :text="buttonText" class="gl-w-full" + block data-testid="labels-select-dropdown-contents" data-qa-selector="labels_dropdown_content" @hide="handleDropdownHide" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue index caeee2df7e5..314ffbaf84c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue @@ -10,7 +10,7 @@ export default { </script> <template> - <div class="gl-display-flex gl-align-items-center"> + <div class="gl-display-flex gl-align-items-center gl-word-break-word"> <span class="dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3" :style="{ 'background-color': label.color }" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue index 0e8da7281d8..2c27a69d587 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue @@ -129,9 +129,6 @@ export default { issuableId() { return this.issuable?.id; }, - isRealtimeEnabled() { - return this.glFeatures.realtimeLabels; - }, }, apollo: { issuable: { @@ -163,7 +160,7 @@ export default { }; }, skip() { - return !this.issuableId || !this.isDropdownVariantSidebar || !this.isRealtimeEnabled; + return !this.issuableId || !this.isDropdownVariantSidebar; }, updateQuery( _, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql new file mode 100644 index 00000000000..a1b16b378b3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql @@ -0,0 +1,22 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +subscription mergeRequestReviewersUpdated($issuableId: IssuableID!) { + mergeRequestReviewersUpdated(issuableId: $issuableId) { + ... on MergeRequest { + id + reviewers { + nodes { + ...User + ...UserAvailability + mergeRequestInteraction { + canMerge + canUpdate + approved + reviewed + } + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js index 8a2bab4cb9a..465ee9aa0d4 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js @@ -1,5 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ - import TodoButton from './todo_button.vue'; export default { diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue index 9683288f937..a2d8b7cbd15 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue @@ -1,5 +1,6 @@ <script> import { GlIntersectionObserver, GlSafeHtmlDirective } from '@gitlab/ui'; +import { scrollToElement } from '~/lib/utils/common_utils'; import ChunkLine from './chunk_line.vue'; /* @@ -23,6 +24,11 @@ export default { SafeHtml: GlSafeHtmlDirective, }, props: { + isFirstChunk: { + type: Boolean, + required: false, + default: false, + }, chunkIndex: { type: Number, required: false, @@ -46,6 +52,11 @@ export default { required: false, default: 0, }, + totalChunks: { + type: Number, + required: false, + default: 0, + }, language: { type: String, required: false, @@ -56,53 +67,68 @@ export default { required: true, }, }, + data() { + return { + isLoading: true, + }; + }, computed: { lines() { return this.content.split('\n'); }, }, + + created() { + if (this.isFirstChunk) { + this.isLoading = false; + return; + } + + window.requestIdleCallback(() => { + this.isLoading = false; + const { hash } = this.$route; + if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) { + // when the last chunk is loaded scroll to the hash + scrollToElement(hash, { behavior: 'auto' }); + } + }); + }, methods: { handleChunkAppear() { if (!this.isHighlighted) { this.$emit('appear', this.chunkIndex); } }, + calculateLineNumber(index) { + return this.startingFrom + index + 1; + }, }, }; </script> <template> - <div> - <gl-intersection-observer @appear="handleChunkAppear"> - <div v-if="isHighlighted"> - <chunk-line - v-for="(line, index) in lines" + <gl-intersection-observer @appear="handleChunkAppear"> + <div v-if="isHighlighted"> + <chunk-line + v-for="(line, index) in lines" + :key="index" + :number="calculateLineNumber(index)" + :content="line" + :language="language" + :blame-path="blamePath" + /> + </div> + <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent"> + <div class="gl-display-flex gl-flex-direction-column content-visibility-auto"> + <span + v-for="(n, index) in totalLines" + v-once + :id="`L${calculateLineNumber(index)}`" :key="index" - :number="startingFrom + index + 1" - :content="line" - :language="language" - :blame-path="blamePath" - /> - </div> - <div v-else class="gl-display-flex"> - <div class="gl-display-flex gl-flex-direction-column"> - <a - v-for="(n, index) in totalLines" - :id="`L${startingFrom + index + 1}`" - :key="index" - class="gl-ml-5 gl-text-transparent" - :href="`#L${startingFrom + index + 1}`" - :data-line-number="startingFrom + index + 1" - data-testid="line-number" - > - {{ startingFrom + index + 1 }} - </a> - </div> - <div - class="gl-white-space-pre-wrap! gl-text-transparent" - data-testid="content" - v-text="content" - ></div> + data-testid="line-number" + v-text="calculateLineNumber(index)" + ></span> </div> - </gl-intersection-observer> - </div> + <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div> + </div> + </gl-intersection-observer> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue index ffd0eea63a1..0bf19f83d86 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue @@ -1,6 +1,7 @@ <script> import { GlSafeHtmlDirective } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { getPageParamValue, getPageSearchString } from '~/blob/utils'; export default { directives: { @@ -25,6 +26,13 @@ export default { required: true, }, }, + computed: { + pageSearchString() { + if (!this.glFeatures.fileLineBlame) return ''; + const page = getPageParamValue(this.number); + return getPageSearchString(this.blamePath, page); + }, + }, }; </script> <template> @@ -35,7 +43,7 @@ export default { <a v-if="glFeatures.fileLineBlame" class="gl-user-select-none gl-shadow-none! file-line-blame" - :href="`${blamePath}#L${number}`" + :href="`${blamePath}${pageSearchString}#L${number}`" ></a> <a :id="`L${number}`" diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js index d957990fe7f..fca2616f069 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js @@ -1,9 +1,17 @@ import packageJsonLinker from './utils/package_json_linker'; import gemspecLinker from './utils/gemspec_linker'; +import godepsJsonLinker from './utils/godeps_json_linker'; +import gemfileLinker from './utils/gemfile_linker'; +import podspecJsonLinker from './utils/podspec_json_linker'; +import composerJsonLinker from './utils/composer_json_linker'; const DEPENDENCY_LINKERS = { package_json: packageJsonLinker, gemspec: gemspecLinker, + godeps_json: godepsJsonLinker, + gemfile: gemfileLinker, + podspec_json: podspecJsonLinker, + composer_json: composerJsonLinker, }; /** diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/composer_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/composer_json_linker.js new file mode 100644 index 00000000000..f5c4c886546 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/composer_json_linker.js @@ -0,0 +1,49 @@ +import { createLink, generateHLJSOpenTag } from './dependency_linker_util'; + +const PACKAGIST_URL = 'https://packagist.org/packages/'; +const DRUPAL_URL = 'https://www.drupal.org/project/'; + +const attrOpenTag = generateHLJSOpenTag('attr'); +const stringOpenTag = generateHLJSOpenTag('string'); +const closeTag = '"</span>'; +const DRUPAL_PROJECT_SEPARATOR = 'drupal/'; +const DEPENDENCY_REGEX = new RegExp( + /* + * Detects dependencies inside of content that is highlighted by Highlight.js + * Example: <span class="hljs-attr">"composer/installers"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"^1.2"</span> + * Group 1: composer/installers + * Group 2: ^1.2 + */ + `${attrOpenTag}([^/]+/[^/]+.)${closeTag}.*${stringOpenTag}(.*[0-9].*)(${closeTag})`, + 'gm', +); + +const handleReplace = (original, packageName, version, dependenciesToLink) => { + const isDrupalDependency = packageName.includes(DRUPAL_PROJECT_SEPARATOR); + const href = isDrupalDependency + ? `${DRUPAL_URL}${packageName.split(DRUPAL_PROJECT_SEPARATOR)[1]}` + : `${PACKAGIST_URL}${packageName}`; + const packageLink = createLink(href, packageName); + const versionLink = createLink(href, version); + const closeAndOpenTag = `${closeTag}: ${attrOpenTag}`; + const dependencyToLink = dependenciesToLink[packageName]; + + if (dependencyToLink && dependencyToLink === version) { + return `${attrOpenTag}${packageLink}${closeAndOpenTag}${versionLink}${closeTag}`; + } + + return original; +}; + +export default (result, raw) => { + const rawParsed = JSON.parse(raw); + + const dependenciesToLink = { + ...rawParsed.require, + ...rawParsed['require-dev'], + }; + + return result.value.replace(DEPENDENCY_REGEX, (original, packageName, version) => + handleReplace(original, packageName, version, dependenciesToLink), + ); +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js index 49704421d6e..c1a1101afad 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js @@ -1,7 +1,27 @@ import { escape } from 'lodash'; export const createLink = (href, innerText) => - `<a href="${escape(href)}" rel="nofollow noreferrer noopener">${escape(innerText)}</a>`; + `<a href="${escape(href)}" target="_blank" rel="nofollow noreferrer noopener">${escape( + innerText, + )}</a>`; export const generateHLJSOpenTag = (type, delimiter = '"') => `<span class="hljs-${escape(type)}">${delimiter}`; + +export const getObjectKeysByKeyName = (obj, keyName, acc) => { + if (obj instanceof Array) { + obj.map((subObj) => getObjectKeysByKeyName(subObj, keyName, acc)); + } else { + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + if (key === keyName) { + acc.push(...Object.keys(obj[key])); + } + if (obj[key] instanceof Object || obj[key] instanceof Array) { + getObjectKeysByKeyName(obj[key], keyName, acc); + } + } + } + } + return acc; +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemfile_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemfile_linker.js new file mode 100644 index 00000000000..81389763f49 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemfile_linker.js @@ -0,0 +1,25 @@ +import { createLink, generateHLJSOpenTag } from './dependency_linker_util'; + +const GEM_URL = 'https://rubygems.org/gems/'; +const GEM_STRING = 'gem </span>'; +const delimiter = '''; +const stringOpenTag = generateHLJSOpenTag('string', delimiter); + +const DEPENDENCY_REGEX = new RegExp( + /* + * Detects dependencies inside of content that is highlighted by Highlight.js + * Example: 'gem </span><span class="hljs-string">'paranoia'' + * Group 1 (packageName) : 'paranoia' + */ + `${GEM_STRING}${stringOpenTag}(.+?(?=${delimiter}))`, + 'gm', +); + +const handleReplace = (packageName) => { + const href = `${GEM_URL}${packageName}`; + const packageLink = createLink(href, packageName); + return `${GEM_STRING}${stringOpenTag}${packageLink}`; +}; +export default (result) => { + return result.value.replace(DEPENDENCY_REGEX, (_, packageName) => handleReplace(packageName)); +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker.js new file mode 100644 index 00000000000..bff8e3cf410 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker.js @@ -0,0 +1,64 @@ +import { createLink, generateHLJSOpenTag } from './dependency_linker_util'; + +const PROTOCOL = 'https://'; +const GODOCS_DOMAIN = 'godoc.org/'; +const REPO_PATH = '/tree/master/'; +const GODOCS_REGEX = /golang.org/; +const GITLAB_REPO_PATH = `/_${REPO_PATH}`; +const REPO_REGEX = `[^/'"]+/[^/'"]+`; +const NESTED_REPO_REGEX = '([^/]+/)+[^/]+?'; +const GITHUB_REPO_REGEX = new RegExp(`(github.com/${REPO_REGEX})/(.+)`); +const GITLAB_REPO_REGEX = new RegExp(`(gitlab.com/${REPO_REGEX})/(.+)`); +const GITLAB_NESTED_REPO_REGEX = new RegExp(`(gitlab.com/${NESTED_REPO_REGEX}).git/(.+)`); +const attrOpenTag = generateHLJSOpenTag('attr'); +const stringOpenTag = generateHLJSOpenTag('string'); +const closeTag = '"</span>'; +const importPathString = + 'ImportPath"</span><span class="hljs-punctuation">:</span><span class=""> </span>'; + +const DEPENDENCY_REGEX = new RegExp( + /* + * Detects dependencies inside of content that is highlighted by Highlight.js + * Example: <span class="hljs-attr">"ImportPath"</span><span class="hljs-punctuation">:</span><span class=""> </span><span class="hljs-string">"github.com/ayufan/golang-kardianos-service"</span> + * Group 1: github.com/ayufan/golang-kardianos-service + */ + `${importPathString}${stringOpenTag}(.*)${closeTag}`, + 'gm', +); + +const replaceRepoPath = (dependency, regex, repoPath) => + dependency.replace(regex, (_, repo, path) => `${PROTOCOL}${repo}${repoPath}${path}`); + +const regexConfigs = [ + { + matcher: GITHUB_REPO_REGEX, + resolver: (dep) => replaceRepoPath(dep, GITHUB_REPO_REGEX, REPO_PATH), + }, + { + matcher: GITLAB_REPO_REGEX, + resolver: (dep) => replaceRepoPath(dep, GITLAB_REPO_REGEX, GITLAB_REPO_PATH), + }, + { + matcher: GITLAB_NESTED_REPO_REGEX, + resolver: (dep) => replaceRepoPath(dep, GITLAB_NESTED_REPO_REGEX, GITLAB_REPO_PATH), + }, + { + matcher: GODOCS_REGEX, + resolver: (dep) => `${PROTOCOL}${GODOCS_DOMAIN}${dep}`, + }, +]; + +const getLinkHref = (dependency) => { + const regexConfig = regexConfigs.find((config) => dependency.match(config.matcher)); + return regexConfig ? regexConfig.resolver(dependency) : `${PROTOCOL}${dependency}`; +}; + +const handleReplace = (dependency) => { + const linkHref = getLinkHref(dependency); + const link = createLink(linkHref, dependency); + return `${importPathString}${attrOpenTag}${link}${closeTag}`; +}; + +export default (result) => { + return result.value.replace(DEPENDENCY_REGEX, (_, dependency) => handleReplace(dependency)); +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker.js new file mode 100644 index 00000000000..e2007fe408b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker.js @@ -0,0 +1,32 @@ +import { createLink, generateHLJSOpenTag, getObjectKeysByKeyName } from './dependency_linker_util'; + +const COCOAPODS_URL = 'https://cocoapods.org/pods/'; +const beginString = generateHLJSOpenTag('attr'); +const endString = + '"</span><span class="hljs-punctuation">:</span><span class=""> </span><span class="hljs-punctuation">\\['; + +const DEPENDENCY_REGEX = new RegExp( + /* + * Detects dependencies inside of content that is highlighted by Highlight.js + * Example: <span class="hljs-attr">"AFNetworking/Security"</span><span class="hljs-punctuation">:</span><span class=""> </span><span class="hljs-punctuation"> [ + * Group 1: AFNetworking/Serialization + */ + `${beginString}([^/]+/?[^/]+.)${endString}`, + 'gm', +); + +const handleReplace = (original, dependency, dependenciesToLink) => { + if (dependenciesToLink.includes(dependency)) { + const href = `${COCOAPODS_URL}${dependency.split('/')[0]}`; + const link = createLink(href, dependency); + return `${beginString}${link}${endString.replace('\\', '')}`; + } + return original; +}; + +export default (result, raw) => { + const dependenciesToLink = getObjectKeysByKeyName(JSON.parse(raw), 'dependencies', []); + return result.value.replace(DEPENDENCY_REGEX, (original, dependency) => + handleReplace(original, dependency, dependenciesToLink), + ); +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js index e0ba4b730a7..3540ac6caf1 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js @@ -22,7 +22,7 @@ const format = (node, kind = '') => { .split(newlineRegex) .map((newline) => generateHLJSTag(kind, newline, true)) .join('\n'); - } else if (node.kind) { + } else if (node.kind || node.sublanguage) { const { children } = node; if (children.length && children.length === 1) { buffer += format(children[0], node.kind); diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index 536b2c8a281..f621a23734a 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -65,6 +65,9 @@ export default { !supportedLanguages.includes(this.blob.language?.toLowerCase()) ); }, + totalChunks() { + return Object.keys(this.chunks).length; + }, }, async created() { addBlobLinksTracking(); @@ -200,6 +203,7 @@ export default { :content="firstChunk.content" :starting-from="firstChunk.startingFrom" :is-highlighted="firstChunk.isHighlighted" + is-first-chunk :language="firstChunk.language" :blame-path="blob.blamePath" /> @@ -217,6 +221,7 @@ export default { :chunk-index="index" :language="chunk.language" :blame-path="blob.blamePath" + :total-chunks="totalChunks" @appear="highlightChunk" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js index e621442e601..84615386fe2 100644 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js @@ -1,4 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import TooltipOnTruncate from './tooltip_on_truncate.vue'; const defaultWidth = '250px'; diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js index 1f0f4cde234..0815fdd9aac 100644 --- a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js @@ -1,5 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ - import { OBSTACLE_TYPES } from './constants'; import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue'; diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index 7e735f358eb..30b7b073ac3 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -3,7 +3,7 @@ import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/datetime_utility'; +import { getTimeago } from '~/lib/utils/datetime_utility'; import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; @@ -62,9 +62,8 @@ export default { issuableId() { return getIdFromGraphQLId(this.issuable.id); }, - createdInPastDay() { - const createdSecondsAgo = differenceInSeconds(new Date(this.issuable.createdAt), new Date()); - return createdSecondsAgo < SECONDS_IN_DAY; + issuableIid() { + return this.issuable.iid; }, author() { return this.issuable.author || {}; @@ -184,7 +183,7 @@ export default { <li :id="`issuable_${issuableId}`" class="issue gl-display-flex! gl-px-5!" - :class="{ closed: issuable.closedAt, today: createdInPastDay }" + :class="{ closed: issuable.closedAt }" :data-labels="labelIdsString" :data-qa-issue-id="issuableId" > @@ -193,6 +192,8 @@ export default { class="issue-check gl-mr-0" :checked="checked" :data-id="issuableId" + :data-iid="issuableIid" + :data-type="issuable.type" @input="$emit('checked-input', $event)" > <span class="gl-sr-only">{{ issuable.title }}</span> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index bc10f84b819..dd3d7c8f4d6 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -7,6 +7,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import issuableEventHub from '~/issues/list/eventhub'; import { DEFAULT_SKELETON_COUNT, PAGE_SIZE_STORAGE_KEY } from '../constants'; import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue'; import IssuableItem from './issuable_item.vue'; @@ -177,6 +178,11 @@ export default { required: false, default: false, }, + showFilteredSearchFriendlyText: { + type: Boolean, + required: false, + default: false, + }, showPageSizeChangeControls: { type: Boolean, required: false, @@ -266,6 +272,7 @@ export default { handleIssuableCheckedInput(issuable, value) { this.checkedIssuables[this.issuableId(issuable)].checked = value; this.$emit('update-legacy-bulk-edit'); + issuableEventHub.$emit('issuables:issuableChecked', issuable, value); }, handleAllIssuablesCheckedInput(value) { Object.keys(this.checkedIssuables).forEach((issuableId) => { @@ -308,6 +315,7 @@ export default { :sync-filter-and-sort="syncFilterAndSort" :show-checkbox="showBulkEditSidebar" :checkbox-checked="allIssuablesChecked" + :show-friendly-text="showFilteredSearchFriendlyText" class="gl-flex-grow-1 gl-border-t-none row-content-block" data-qa-selector="issuable_search_container" @checked-input="handleAllIssuablesCheckedInput" diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js index 6a4f671abb9..a6628fa0f9f 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js @@ -90,7 +90,7 @@ const createStatusMessage = ({ reportType, status, total }) => { if (status) { message = __('%{reportType} %{status}'); } else if (!total) { - message = __('%{reportType} detected no %{totalStart}new%{totalEnd} vulnerabilities.'); + message = __('%{reportType} detected no new vulnerabilities.'); } else { message = __( '%{reportType} detected %{totalStart}%{total}%{totalEnd} potential %{vulnMessage}', |