Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
402c915cb5
commit
f34077e881
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -2,6 +2,23 @@
|
|||
documentation](doc/development/changelog.md) for instructions on adding your own
|
||||
entry.
|
||||
|
||||
## 15.1.2 (2022-07-05)
|
||||
|
||||
### Fixed (3 changes)
|
||||
|
||||
- [Resolve "White screen of death on creating new project"](gitlab-org/gitlab@b737280d402aa88f723ada9885ccca22fa4457b5) ([merge request](gitlab-org/gitlab!91668))
|
||||
- [Fix agent token modal](gitlab-org/gitlab@6fdffc4a534f67953a1555a0e4e35e4bd2bcb960) ([merge request](gitlab-org/gitlab!91668))
|
||||
- [Resolve "Gitlab doesn't detect the deployment pods after K8s cluster upgrade to v1.22"](gitlab-org/gitlab@5eb84d7d96189f7119aa325e83a3723942cc14ba) ([merge request](gitlab-org/gitlab!91668))
|
||||
|
||||
### Changed (2 changes)
|
||||
|
||||
- [Update gitaly_cgroups metric name in docs](gitlab-org/gitlab@1af956596f052446f7ee2d42635b891670ddccd4) ([merge request](gitlab-org/gitlab!91668))
|
||||
- [Refactor add populate commit permission migration](gitlab-org/gitlab@bc80cc41c2b90b8e459055c5ec1885941798f3c2) ([merge request](gitlab-org/gitlab!91668)) **GitLab Enterprise Edition**
|
||||
|
||||
### Removed (1 change)
|
||||
|
||||
- [Geo Sites Form - Remove Beta Badge](gitlab-org/gitlab@2feffa8e272aa8d9e608ad3e510a93fda93b7fcb) ([merge request](gitlab-org/gitlab!91668)) **GitLab Enterprise Edition**
|
||||
|
||||
## 15.1.1 (2022-06-30)
|
||||
|
||||
### Security (16 changes)
|
||||
|
|
|
|||
12
Gemfile.lock
12
Gemfile.lock
|
|
@ -1,15 +1,15 @@
|
|||
PATH
|
||||
remote: vendor/gems/error_tracking_open_api
|
||||
specs:
|
||||
error_tracking_open_api (1.0.0)
|
||||
typhoeus (~> 1.0, >= 1.0.1)
|
||||
|
||||
PATH
|
||||
remote: vendor/gems/devise-pbkdf2-encryptable
|
||||
specs:
|
||||
devise-pbkdf2-encryptable (0.0.0)
|
||||
devise (~> 4.0)
|
||||
|
||||
PATH
|
||||
remote: vendor/gems/error_tracking_open_api
|
||||
specs:
|
||||
error_tracking_open_api (1.0.0)
|
||||
typhoeus (~> 1.0, >= 1.0.1)
|
||||
|
||||
PATH
|
||||
remote: vendor/gems/ipynbdiff
|
||||
specs:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { GlCollapse, GlButton, GlPopover, GlSkeletonLoader } from '@gitlab/ui';
|
||||
import { getCookie, setCookie, parseBoolean, isLoggedIn } from '~/lib/utils/common_utils';
|
||||
import { GlAccordion, GlAccordionItem, GlSkeletonLoader } from '@gitlab/ui';
|
||||
import { isLoggedIn } from '~/lib/utils/common_utils';
|
||||
|
||||
import { s__ } from '~/locale';
|
||||
import Participants from '~/sidebar/components/participants/participants.vue';
|
||||
|
|
@ -17,9 +17,8 @@ export default {
|
|||
DesignDiscussion,
|
||||
DesignNoteSignedOut,
|
||||
Participants,
|
||||
GlCollapse,
|
||||
GlButton,
|
||||
GlPopover,
|
||||
GlAccordion,
|
||||
GlAccordionItem,
|
||||
GlSkeletonLoader,
|
||||
DesignTodoButton,
|
||||
},
|
||||
|
|
@ -58,7 +57,7 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
isResolvedCommentsPopoverHidden: parseBoolean(getCookie(this.$options.cookieKey)),
|
||||
isResolvedDiscussionsExpanded: this.resolvedDiscussionsExpanded,
|
||||
discussionWithOpenForm: '',
|
||||
isLoggedIn: isLoggedIn(),
|
||||
};
|
||||
|
|
@ -79,18 +78,22 @@ export default {
|
|||
resolvedDiscussions() {
|
||||
return this.discussions.filter((discussion) => discussion.resolved);
|
||||
},
|
||||
hasResolvedDiscussions() {
|
||||
return this.resolvedDiscussions.length > 0;
|
||||
},
|
||||
resolvedDiscussionsTitle() {
|
||||
return `${this.$options.i18n.resolveCommentsToggleText} (${this.resolvedDiscussions.length})`;
|
||||
},
|
||||
unresolvedDiscussions() {
|
||||
return this.discussions.filter((discussion) => !discussion.resolved);
|
||||
},
|
||||
resolvedCommentsToggleIcon() {
|
||||
return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
isResolvedCommentsPopoverHidden(newVal) {
|
||||
if (!newVal) {
|
||||
this.$refs.resolvedComments.scrollIntoView();
|
||||
}
|
||||
resolvedDiscussionsExpanded(resolvedDiscussionsExpanded) {
|
||||
this.isResolvedDiscussionsExpanded = resolvedDiscussionsExpanded;
|
||||
},
|
||||
isResolvedDiscussionsExpanded() {
|
||||
this.$emit('toggleResolvedComments');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
|
@ -100,8 +103,6 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
handleSidebarClick() {
|
||||
this.isResolvedCommentsPopoverHidden = true;
|
||||
setCookie(this.$options.cookieKey, 'true', { expires: 365 * 10 });
|
||||
this.updateActiveDiscussion();
|
||||
},
|
||||
updateActiveDiscussion(id) {
|
||||
|
|
@ -121,8 +122,9 @@ export default {
|
|||
this.discussionWithOpenForm = id;
|
||||
},
|
||||
},
|
||||
resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
|
||||
cookieKey: 'hide_design_resolved_comments_popover',
|
||||
i18n: {
|
||||
resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
@ -181,40 +183,12 @@ export default {
|
|||
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
|
||||
@open-form="updateDiscussionWithOpenForm"
|
||||
/>
|
||||
<template v-if="resolvedDiscussions.length > 0">
|
||||
<gl-button
|
||||
id="resolved-comments"
|
||||
ref="resolvedComments"
|
||||
data-testid="resolved-comments"
|
||||
:icon="resolvedCommentsToggleIcon"
|
||||
variant="link"
|
||||
class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
|
||||
@click="$emit('toggleResolvedComments')"
|
||||
>{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }})
|
||||
</gl-button>
|
||||
<gl-popover
|
||||
v-if="!isResolvedCommentsPopoverHidden"
|
||||
:show="!isResolvedCommentsPopoverHidden"
|
||||
target="resolved-comments"
|
||||
container="popovercontainer"
|
||||
placement="top"
|
||||
:title="s__('DesignManagement|Resolved Comments')"
|
||||
<gl-accordion v-if="hasResolvedDiscussions" :header-level="3" class="gl-mb-5">
|
||||
<gl-accordion-item
|
||||
v-model="isResolvedDiscussionsExpanded"
|
||||
:title="resolvedDiscussionsTitle"
|
||||
header-class="gl-mb-5!"
|
||||
>
|
||||
<p>
|
||||
{{
|
||||
s__(
|
||||
'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<a
|
||||
href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>{{ s__('DesignManagement|Learn more about resolving comments') }}</a
|
||||
>
|
||||
</gl-popover>
|
||||
<gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3">
|
||||
<design-discussion
|
||||
v-for="discussion in resolvedDiscussions"
|
||||
:key="discussion.id"
|
||||
|
|
@ -232,8 +206,8 @@ export default {
|
|||
@open-form="updateDiscussionWithOpenForm"
|
||||
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
|
||||
/>
|
||||
</gl-collapse>
|
||||
</template>
|
||||
</gl-accordion-item>
|
||||
</gl-accordion>
|
||||
<slot name="reply-form"></slot>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
<script>
|
||||
import { GlButton, GlIcon } from '@gitlab/ui';
|
||||
import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/constants';
|
||||
|
||||
export default {
|
||||
components: { GlButton, GlIcon },
|
||||
props: {
|
||||
line: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
codeQuality: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
severityClass(severity) {
|
||||
return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown;
|
||||
},
|
||||
severityIcon(severity) {
|
||||
return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-testid="diff-codequality" class="gl-relative">
|
||||
<ul
|
||||
class="gl-list-style-none gl-mb-0 gl-p-0 codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10"
|
||||
>
|
||||
<li
|
||||
v-for="finding in codeQuality"
|
||||
:key="finding.description"
|
||||
class="gl-pt-1 gl-pb-1 gl-pl-3 gl-border-solid gl-border-bottom-0 gl-border-right-0 gl-border-1 gl-border-gray-100"
|
||||
>
|
||||
<gl-icon
|
||||
:size="12"
|
||||
:name="severityIcon(finding.severity)"
|
||||
:class="severityClass(finding.severity)"
|
||||
class="codequality-severity-icon"
|
||||
/>
|
||||
{{ finding.description }}
|
||||
</li>
|
||||
</ul>
|
||||
<gl-button
|
||||
data-testid="diff-codequality-close"
|
||||
category="tertiary"
|
||||
size="small"
|
||||
icon="close"
|
||||
class="gl-absolute gl-right-2 gl-top-2"
|
||||
@click="$emit('hideCodeQualityFindings', line)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -274,6 +274,9 @@ export default {
|
|||
v-if="$options.showCodequalityLeft(props)"
|
||||
:codequality="props.line.left.codequality"
|
||||
:file-path="props.filePath"
|
||||
@showCodeQualityFindings="
|
||||
listeners.toggleCodeQualityFindings(props.line.left.codequality[0].line)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -395,6 +398,9 @@ export default {
|
|||
:codequality="props.line.right.codequality"
|
||||
:file-path="props.filePath"
|
||||
data-testid="codeQualityIcon"
|
||||
@showCodeQualityFindings="
|
||||
listeners.toggleCodeQualityFindings(props.line.right.codequality[0].line)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
|
||||
import { mapGetters, mapState, mapActions } from 'vuex';
|
||||
import { IdState } from 'vendor/vue-virtual-scroller';
|
||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import DraftNote from '~/batch_comments/components/draft_note.vue';
|
||||
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
|
||||
import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
|
||||
import { hide } from '~/tooltips';
|
||||
import { pickDirection } from '../utils/diff_line';
|
||||
import DiffCommentCell from './diff_comment_cell.vue';
|
||||
import DiffCodeQuality from './diff_code_quality.vue';
|
||||
import DiffExpansionCell from './diff_expansion_cell.vue';
|
||||
import DiffRow from './diff_row.vue';
|
||||
import { isHighlighted } from './diff_row_utils';
|
||||
|
|
@ -17,12 +19,17 @@ export default {
|
|||
DiffExpansionCell,
|
||||
DiffRow,
|
||||
DiffCommentCell,
|
||||
DiffCodeQuality,
|
||||
DraftNote,
|
||||
},
|
||||
directives: {
|
||||
SafeHtml,
|
||||
},
|
||||
mixins: [draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash })],
|
||||
mixins: [
|
||||
draftCommentsMixin,
|
||||
IdState({ idProp: (vm) => vm.diffFile.file_hash }),
|
||||
glFeatureFlagsMixin(),
|
||||
],
|
||||
props: {
|
||||
diffFile: {
|
||||
type: Object,
|
||||
|
|
@ -43,6 +50,11 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
codeQualityExpandedLines: [],
|
||||
};
|
||||
},
|
||||
idState() {
|
||||
return {
|
||||
dragStart: null,
|
||||
|
|
@ -84,6 +96,23 @@ export default {
|
|||
}
|
||||
this.idState.dragStart = line;
|
||||
},
|
||||
parseCodeQuality(line) {
|
||||
return (line.left ?? line.right)?.codequality;
|
||||
},
|
||||
|
||||
hideCodeQualityFindings(line) {
|
||||
const index = this.codeQualityExpandedLines.indexOf(line);
|
||||
if (index > -1) {
|
||||
this.codeQualityExpandedLines.splice(index, 1);
|
||||
}
|
||||
},
|
||||
toggleCodeQualityFindings(line) {
|
||||
if (!this.codeQualityExpandedLines.includes(line)) {
|
||||
this.codeQualityExpandedLines.push(line);
|
||||
} else {
|
||||
this.hideCodeQualityFindings(line);
|
||||
}
|
||||
},
|
||||
onDragOver(line) {
|
||||
if (line.chunk !== this.idState.dragStart.chunk) return;
|
||||
|
||||
|
|
@ -125,15 +154,16 @@ export default {
|
|||
},
|
||||
handleParallelLineMouseDown(e) {
|
||||
const line = e.target.closest('.diff-td');
|
||||
const table = line.closest('.diff-table');
|
||||
if (line) {
|
||||
const table = line.closest('.diff-table');
|
||||
table.classList.remove('left-side-selected', 'right-side-selected');
|
||||
const [lineClass] = ['left-side', 'right-side'].filter((name) =>
|
||||
line.classList.contains(name),
|
||||
);
|
||||
|
||||
table.classList.remove('left-side-selected', 'right-side-selected');
|
||||
const [lineClass] = ['left-side', 'right-side'].filter((name) =>
|
||||
line.classList.contains(name),
|
||||
);
|
||||
|
||||
if (lineClass) {
|
||||
table.classList.add(`${lineClass}-selected`);
|
||||
if (lineClass) {
|
||||
table.classList.add(`${lineClass}-selected`);
|
||||
}
|
||||
}
|
||||
},
|
||||
getCountBetweenIndex(index) {
|
||||
|
|
@ -148,6 +178,9 @@ export default {
|
|||
Number(this.diffLines[index - 1].left.new_line)
|
||||
);
|
||||
},
|
||||
getCodeQualityLine(line) {
|
||||
return this.parseCodeQuality(line)?.[0]?.line;
|
||||
},
|
||||
},
|
||||
userColorScheme: window.gon.user_color_scheme,
|
||||
};
|
||||
|
|
@ -190,6 +223,7 @@ export default {
|
|||
:coverage-loaded="coverageLoaded"
|
||||
@showCommentForm="(code) => singleLineComment(code, line)"
|
||||
@setHighlightedRow="setHighlightedRow"
|
||||
@toggleCodeQualityFindings="toggleCodeQualityFindings"
|
||||
@toggleLineDiscussions="
|
||||
({ lineCode, expanded }) =>
|
||||
toggleLineDiscussions({ lineCode, fileHash: diffFile.file_hash, expanded })
|
||||
|
|
@ -198,6 +232,17 @@ export default {
|
|||
@startdragging="onStartDragging"
|
||||
@stopdragging="onStopDragging"
|
||||
/>
|
||||
|
||||
<diff-code-quality
|
||||
v-if="
|
||||
glFeatures.refactorCodeQualityInlineFindings &&
|
||||
codeQualityExpandedLines.includes(getCodeQualityLine(line))
|
||||
"
|
||||
:key="line.line_code"
|
||||
:line="getCodeQualityLine(line)"
|
||||
:code-quality="parseCodeQuality(line)"
|
||||
@hideCodeQualityFindings="hideCodeQualityFindings"
|
||||
/>
|
||||
<div
|
||||
v-if="line.renderCommentRow"
|
||||
:key="`dcr-${line.line_code || index}`"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
<script>
|
||||
import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui';
|
||||
import {
|
||||
GlSearchBoxByType,
|
||||
GlOutsideDirective as Outside,
|
||||
GlIcon,
|
||||
GlToken,
|
||||
GlResizeObserverDirective,
|
||||
} from '@gitlab/ui';
|
||||
import { mapState, mapActions, mapGetters } from 'vuex';
|
||||
import { debounce } from 'lodash';
|
||||
import { visitUrl } from '~/lib/utils/url_utility';
|
||||
import { truncate } from '~/lib/utils/text_utility';
|
||||
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
|
||||
import { s__, sprintf } from '~/locale';
|
||||
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
|
||||
|
|
@ -12,6 +19,8 @@ import {
|
|||
SEARCH_INPUT_DESCRIPTION,
|
||||
SEARCH_RESULTS_DESCRIPTION,
|
||||
SEARCH_SHORTCUTS_MIN_CHARACTERS,
|
||||
SCOPE_TOKEN_MAX_LENGTH,
|
||||
INPUT_FIELD_PADDING,
|
||||
} from '../constants';
|
||||
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
|
||||
import HeaderSearchDefaultItems from './header_search_default_items.vue';
|
||||
|
|
@ -34,14 +43,17 @@ export default {
|
|||
'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.',
|
||||
),
|
||||
searchResultsLoading: s__('GlobalSearch|Search results are loading'),
|
||||
searchResultsScope: s__('GlobalSearch|in %{scope}'),
|
||||
},
|
||||
directives: { Outside },
|
||||
directives: { Outside, GlResizeObserverDirective },
|
||||
components: {
|
||||
GlSearchBoxByType,
|
||||
HeaderSearchDefaultItems,
|
||||
HeaderSearchScopedItems,
|
||||
HeaderSearchAutocompleteItems,
|
||||
DropdownKeyboardNavigation,
|
||||
GlIcon,
|
||||
GlToken,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -50,8 +62,8 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(['search', 'loading']),
|
||||
...mapGetters(['searchQuery', 'searchOptions', 'autocompleteGroupedSearchOptions']),
|
||||
...mapState(['search', 'loading', 'searchContext']),
|
||||
...mapGetters(['searchQuery', 'searchOptions']),
|
||||
searchText: {
|
||||
get() {
|
||||
return this.search;
|
||||
|
|
@ -70,16 +82,17 @@ export default {
|
|||
return Boolean(gon?.current_username);
|
||||
},
|
||||
showSearchDropdown() {
|
||||
const hasResultsUnderMinCharacters =
|
||||
this.searchText?.length === 1 ? this?.autocompleteGroupedSearchOptions?.length > 0 : true;
|
||||
if (!this.showDropdown || !this.isLoggedIn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.showDropdown && this.isLoggedIn && hasResultsUnderMinCharacters;
|
||||
return this.searchOptions?.length > 0;
|
||||
},
|
||||
showDefaultItems() {
|
||||
return !this.searchText;
|
||||
},
|
||||
showShortcuts() {
|
||||
return this.searchText && this.searchText?.length >= SEARCH_SHORTCUTS_MIN_CHARACTERS;
|
||||
showScopes() {
|
||||
return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
|
||||
},
|
||||
defaultIndex() {
|
||||
if (this.showDefaultItems) {
|
||||
|
|
@ -88,11 +101,11 @@ export default {
|
|||
|
||||
return FIRST_DROPDOWN_INDEX;
|
||||
},
|
||||
|
||||
searchInputDescribeBy() {
|
||||
if (this.isLoggedIn) {
|
||||
return this.$options.i18n.searchInputDescribeByWithDropdown;
|
||||
}
|
||||
|
||||
return this.$options.i18n.searchInputDescribeByNoDropdown;
|
||||
},
|
||||
dropdownResultsDescription() {
|
||||
|
|
@ -112,8 +125,26 @@ export default {
|
|||
count: this.searchOptions.length,
|
||||
});
|
||||
},
|
||||
headerSearchActivityDescriptor() {
|
||||
return this.showDropdown ? 'is-active' : 'is-not-active';
|
||||
searchBarStateIndicator() {
|
||||
const hasIcon =
|
||||
this.searchContext?.project || this.searchContext?.group ? 'has-icon' : 'has-no-icon';
|
||||
const isSearching = this.showScopes ? 'is-searching' : 'is-not-searching';
|
||||
const isActive = this.showSearchDropdown ? 'is-active' : 'is-not-active';
|
||||
return `${isActive} ${isSearching} ${hasIcon}`;
|
||||
},
|
||||
searchBarItem() {
|
||||
return this.searchOptions?.[0];
|
||||
},
|
||||
infieldHelpContent() {
|
||||
return this.searchBarItem?.scope || this.searchBarItem?.description;
|
||||
},
|
||||
infieldHelpIcon() {
|
||||
return this.searchBarItem?.icon;
|
||||
},
|
||||
scopeTokenTitle() {
|
||||
return sprintf(this.$options.i18n.searchResultsScope, {
|
||||
scope: this.infieldHelpContent,
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -127,6 +158,9 @@ export default {
|
|||
this.$emit('toggleDropdown', this.showDropdown);
|
||||
},
|
||||
submitSearch() {
|
||||
if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
return visitUrl(this.currentFocusedOption?.url || this.searchQuery);
|
||||
},
|
||||
getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
|
||||
|
|
@ -136,8 +170,19 @@ export default {
|
|||
this.fetchAutocompleteOptions();
|
||||
}
|
||||
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
|
||||
getTruncatedScope(scope) {
|
||||
return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
|
||||
},
|
||||
observeTokenWidth({ contentRect: { width } }) {
|
||||
const inputField = this.$refs?.searchInputBox?.$el?.querySelector('input');
|
||||
if (!inputField) {
|
||||
return;
|
||||
}
|
||||
inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`;
|
||||
},
|
||||
},
|
||||
SEARCH_BOX_INDEX,
|
||||
FIRST_DROPDOWN_INDEX,
|
||||
SEARCH_INPUT_DESCRIPTION,
|
||||
SEARCH_RESULTS_DESCRIPTION,
|
||||
};
|
||||
|
|
@ -149,10 +194,12 @@ export default {
|
|||
role="search"
|
||||
:aria-label="$options.i18n.searchGitlab"
|
||||
class="header-search gl-relative gl-rounded-base gl-w-full"
|
||||
:class="headerSearchActivityDescriptor"
|
||||
:class="searchBarStateIndicator"
|
||||
data-testid="header-search-form"
|
||||
>
|
||||
<gl-search-box-by-type
|
||||
id="search"
|
||||
ref="searchInputBox"
|
||||
v-model="searchText"
|
||||
role="searchbox"
|
||||
class="gl-z-index-1"
|
||||
|
|
@ -165,7 +212,28 @@ export default {
|
|||
@click="openDropdown"
|
||||
@input="getAutocompleteOptions"
|
||||
@keydown.enter.stop.prevent="submitSearch"
|
||||
@keydown.esc.stop.prevent="closeDropdown"
|
||||
/>
|
||||
<gl-token
|
||||
v-if="showScopes"
|
||||
v-gl-resize-observer-directive="observeTokenWidth"
|
||||
class="in-search-scope-help"
|
||||
:view-only="true"
|
||||
:title="scopeTokenTitle"
|
||||
><gl-icon
|
||||
v-if="infieldHelpIcon"
|
||||
class="gl-mr-2"
|
||||
:aria-label="infieldHelpContent"
|
||||
:name="infieldHelpIcon"
|
||||
:size="16"
|
||||
/>{{
|
||||
getTruncatedScope(
|
||||
sprintf($options.i18n.searchResultsScope, {
|
||||
scope: infieldHelpContent,
|
||||
}),
|
||||
)
|
||||
}}
|
||||
</gl-token>
|
||||
<span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{
|
||||
searchInputDescribeBy
|
||||
}}</span>
|
||||
|
|
@ -187,7 +255,7 @@ export default {
|
|||
<dropdown-keyboard-navigation
|
||||
v-model="currentFocusIndex"
|
||||
:max="searchOptions.length - 1"
|
||||
:min="$options.SEARCH_BOX_INDEX"
|
||||
:min="$options.FIRST_DROPDOWN_INDEX"
|
||||
:default-index="defaultIndex"
|
||||
@tab="closeDropdown"
|
||||
/>
|
||||
|
|
@ -197,7 +265,7 @@ export default {
|
|||
/>
|
||||
<template v-else>
|
||||
<header-search-scoped-items
|
||||
v-if="showShortcuts"
|
||||
v-if="showScopes"
|
||||
:current-focused-option="currentFocusedOption"
|
||||
/>
|
||||
<header-search-autocomplete-items :current-focused-option="currentFocusedOption" />
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
<script>
|
||||
import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
|
||||
import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui';
|
||||
import { mapState, mapGetters } from 'vuex';
|
||||
import { __, sprintf } from '~/locale';
|
||||
import { s__, sprintf } from '~/locale';
|
||||
import { truncate } from '~/lib/utils/text_utility';
|
||||
import { SCOPE_TOKEN_MAX_LENGTH } from '../constants';
|
||||
|
||||
export default {
|
||||
name: 'HeaderSearchScopedItems',
|
||||
components: {
|
||||
GlDropdownItem,
|
||||
GlDropdownDivider,
|
||||
GlIcon,
|
||||
GlToken,
|
||||
},
|
||||
props: {
|
||||
currentFocusedOption: {
|
||||
|
|
@ -25,12 +28,21 @@ export default {
|
|||
return this.currentFocusedOption?.html_id === option.html_id;
|
||||
},
|
||||
ariaLabel(option) {
|
||||
return sprintf(__('%{search} %{description} %{scope}'), {
|
||||
return sprintf(s__('GlobalSearch| %{search} %{description} %{scope}'), {
|
||||
search: this.search,
|
||||
description: option.description,
|
||||
description: option.description || option.icon,
|
||||
scope: option.scope || '',
|
||||
});
|
||||
},
|
||||
titleLabel(option) {
|
||||
return sprintf(s__('GlobalSearch|in %{scope}'), {
|
||||
search: this.search,
|
||||
scope: option.scope || option.description,
|
||||
});
|
||||
},
|
||||
getTruncatedScope(scope) {
|
||||
return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -42,18 +54,30 @@ export default {
|
|||
:id="option.html_id"
|
||||
:ref="option.html_id"
|
||||
:key="option.html_id"
|
||||
class="gl-max-w-full"
|
||||
:class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
|
||||
:aria-selected="isOptionFocused(option)"
|
||||
:aria-label="ariaLabel(option)"
|
||||
tabindex="-1"
|
||||
:href="option.url"
|
||||
:title="titleLabel(option)"
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
"<span class="gl-font-weight-bold">{{ search }}</span
|
||||
>" {{ option.description }}
|
||||
<span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span>
|
||||
<span
|
||||
ref="token-text-content"
|
||||
class="gl-display-flex gl-justify-content-start search-text-content gl-line-height-24 gl-align-items-start gl-flex-direction-row gl-w-full"
|
||||
>
|
||||
<gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-relative gl-pt-2" />
|
||||
<span class="gl-flex-grow-1 gl-relative">
|
||||
<gl-token
|
||||
class="in-dropdown-scope-help has-icon gl-flex-shrink-0 gl-relative gl-white-space-nowrap gl-float-right gl-mr-n3!"
|
||||
:view-only="true"
|
||||
>
|
||||
<gl-icon v-if="option.icon" :name="option.icon" class="gl-mr-2" />
|
||||
<span>{{ getTruncatedScope(titleLabel(option)) }}</span>
|
||||
</gl-token>
|
||||
{{ search }}
|
||||
</span>
|
||||
</span>
|
||||
</gl-dropdown-item>
|
||||
<gl-dropdown-divider v-if="autocompleteGroupedSearchOptions.length > 0" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -10,15 +10,21 @@ export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a re
|
|||
|
||||
export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created");
|
||||
|
||||
export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|in all GitLab');
|
||||
export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|all GitLab');
|
||||
|
||||
export const MSG_IN_GROUP = s__('GlobalSearch|in group');
|
||||
export const MSG_IN_GROUP = s__('GlobalSearch|group');
|
||||
|
||||
export const MSG_IN_PROJECT = s__('GlobalSearch|in project');
|
||||
export const MSG_IN_PROJECT = s__('GlobalSearch|project');
|
||||
|
||||
export const GROUPS_CATEGORY = 'Groups';
|
||||
export const ICON_PROJECT = 'project';
|
||||
|
||||
export const PROJECTS_CATEGORY = 'Projects';
|
||||
export const ICON_GROUP = 'group';
|
||||
|
||||
export const ICON_SUBGROUP = 'subgroup';
|
||||
|
||||
export const GROUPS_CATEGORY = s__('GlobalSearch|Groups');
|
||||
|
||||
export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects');
|
||||
|
||||
export const ISSUES_CATEGORY = 'Recent issues';
|
||||
|
||||
|
|
@ -39,3 +45,7 @@ export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2;
|
|||
export const SEARCH_INPUT_DESCRIPTION = 'search-input-description';
|
||||
|
||||
export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description';
|
||||
|
||||
export const SCOPE_TOKEN_MAX_LENGTH = 36;
|
||||
|
||||
export const INPUT_FIELD_PADDING = 52;
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@ import {
|
|||
MSG_MR_ASSIGNED_TO_ME,
|
||||
MSG_MR_IM_REVIEWER,
|
||||
MSG_MR_IVE_CREATED,
|
||||
MSG_IN_PROJECT,
|
||||
MSG_IN_GROUP,
|
||||
ICON_GROUP,
|
||||
ICON_SUBGROUP,
|
||||
ICON_PROJECT,
|
||||
MSG_IN_ALL_GITLAB,
|
||||
PROJECTS_CATEGORY,
|
||||
GROUPS_CATEGORY,
|
||||
SEARCH_SHORTCUTS_MIN_CHARACTERS,
|
||||
} from '../constants';
|
||||
|
||||
export const searchQuery = (state) => {
|
||||
|
|
@ -149,7 +153,8 @@ export const scopedSearchOptions = (state, getters) => {
|
|||
options.push({
|
||||
html_id: 'scoped-in-project',
|
||||
scope: state.searchContext.project?.name || '',
|
||||
description: MSG_IN_PROJECT,
|
||||
scopeCategory: PROJECTS_CATEGORY,
|
||||
icon: ICON_PROJECT,
|
||||
url: getters.projectUrl,
|
||||
});
|
||||
}
|
||||
|
|
@ -158,7 +163,8 @@ export const scopedSearchOptions = (state, getters) => {
|
|||
options.push({
|
||||
html_id: 'scoped-in-group',
|
||||
scope: state.searchContext.group?.name || '',
|
||||
description: MSG_IN_GROUP,
|
||||
scopeCategory: GROUPS_CATEGORY,
|
||||
icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP,
|
||||
url: getters.groupUrl,
|
||||
});
|
||||
}
|
||||
|
|
@ -190,6 +196,7 @@ export const autocompleteGroupedSearchOptions = (state) => {
|
|||
results.push(groupedOptions[option.category]);
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
|
|
@ -205,5 +212,9 @@ export const searchOptions = (state, getters) => {
|
|||
[],
|
||||
);
|
||||
|
||||
if (state.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) {
|
||||
return sortedAutocompleteOptions;
|
||||
}
|
||||
|
||||
return getters.scopedSearchOptions.concat(sortedAutocompleteOptions);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script>
|
||||
import { GlTabs, GlTab } from '@gitlab/ui';
|
||||
import { debounce } from 'lodash';
|
||||
import { mapState, mapGetters, mapActions } from 'vuex';
|
||||
import {
|
||||
|
|
@ -45,6 +46,8 @@ const MARKDOWN_FILE_TYPE = 'markdown';
|
|||
export default {
|
||||
name: 'RepoEditor',
|
||||
components: {
|
||||
GlTabs,
|
||||
GlTab,
|
||||
FileAlert,
|
||||
ContentViewer,
|
||||
DiffViewer,
|
||||
|
|
@ -121,16 +124,6 @@ export default {
|
|||
isPreviewViewMode() {
|
||||
return this.fileEditor.viewMode === FILE_VIEW_MODE_PREVIEW;
|
||||
},
|
||||
editTabCSS() {
|
||||
return {
|
||||
active: this.isEditorViewMode,
|
||||
};
|
||||
},
|
||||
previewTabCSS() {
|
||||
return {
|
||||
active: this.isPreviewViewMode,
|
||||
};
|
||||
},
|
||||
showEditor() {
|
||||
return !this.shouldHideEditor && this.isEditorViewMode;
|
||||
},
|
||||
|
|
@ -487,28 +480,18 @@ export default {
|
|||
|
||||
<template>
|
||||
<div id="ide" class="blob-viewer-container blob-editor-container">
|
||||
<div v-if="showTabs" class="ide-mode-tabs clearfix">
|
||||
<ul class="nav-links float-left border-bottom-0">
|
||||
<li :class="editTabCSS">
|
||||
<a
|
||||
href="javascript:void(0);"
|
||||
role="button"
|
||||
data-testid="edit-tab"
|
||||
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
|
||||
>{{ __('Edit') }}</a
|
||||
>
|
||||
</li>
|
||||
<li :class="previewTabCSS">
|
||||
<a
|
||||
href="javascript:void(0);"
|
||||
role="button"
|
||||
data-testid="preview-tab"
|
||||
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
|
||||
>{{ previewMode.previewTitle }}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<gl-tabs v-if="showTabs" content-class="gl-display-none">
|
||||
<gl-tab
|
||||
:title="__('Edit')"
|
||||
data-testid="edit-tab"
|
||||
@click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
|
||||
/>
|
||||
<gl-tab
|
||||
:title="previewMode.previewTitle"
|
||||
data-testid="preview-tab"
|
||||
@click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
|
||||
/>
|
||||
</gl-tabs>
|
||||
<file-alert v-if="alertKey" :alert-key="alertKey" />
|
||||
<file-templates-bar v-else-if="showFileTemplatesBar(file.name)" />
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ import {
|
|||
UPDATED_DESC,
|
||||
urlSortParams,
|
||||
} from '../constants';
|
||||
|
||||
import eventHub from '../eventhub';
|
||||
import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql';
|
||||
import searchLabelsQuery from '../queries/search_labels.query.graphql';
|
||||
|
|
@ -172,6 +173,7 @@ export default {
|
|||
showBulkEditSidebar: false,
|
||||
sortKey: CREATED_DESC,
|
||||
state: IssuableStates.Opened,
|
||||
pageSize: PAGE_SIZE,
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
|
|
@ -423,6 +425,10 @@ export default {
|
|||
showPaginationControls() {
|
||||
return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage);
|
||||
},
|
||||
showPageSizeControls() {
|
||||
/** only show page size controls when the tab count is greater than the default/minimum page size control i.e 20 in this case */
|
||||
return this.currentTabCount > PAGE_SIZE;
|
||||
},
|
||||
sortOptions() {
|
||||
return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature);
|
||||
},
|
||||
|
|
@ -445,8 +451,8 @@ export default {
|
|||
...this.urlFilterParams,
|
||||
first_page_size: this.pageParams.firstPageSize,
|
||||
last_page_size: this.pageParams.lastPageSize,
|
||||
page_after: this.pageParams.afterCursor,
|
||||
page_before: this.pageParams.beforeCursor,
|
||||
page_after: this.pageParams.afterCursor ?? undefined,
|
||||
page_before: this.pageParams.beforeCursor ?? undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
@ -555,7 +561,7 @@ export default {
|
|||
},
|
||||
handleClickTab(state) {
|
||||
if (this.state !== state) {
|
||||
this.pageParams = getInitialPageParams(this.sortKey);
|
||||
this.pageParams = getInitialPageParams(this.pageSize);
|
||||
}
|
||||
this.state = state;
|
||||
|
||||
|
|
@ -570,7 +576,7 @@ export default {
|
|||
return;
|
||||
}
|
||||
|
||||
this.pageParams = getInitialPageParams(this.sortKey);
|
||||
this.pageParams = getInitialPageParams(this.pageSize);
|
||||
this.filterTokens = filter;
|
||||
|
||||
this.$router.push({ query: this.urlParams });
|
||||
|
|
@ -578,7 +584,7 @@ export default {
|
|||
handleNextPage() {
|
||||
this.pageParams = {
|
||||
afterCursor: this.pageInfo.endCursor,
|
||||
firstPageSize: PAGE_SIZE,
|
||||
firstPageSize: this.pageSize,
|
||||
};
|
||||
scrollUp();
|
||||
|
||||
|
|
@ -587,7 +593,7 @@ export default {
|
|||
handlePreviousPage() {
|
||||
this.pageParams = {
|
||||
beforeCursor: this.pageInfo.startCursor,
|
||||
lastPageSize: PAGE_SIZE,
|
||||
lastPageSize: this.pageSize,
|
||||
};
|
||||
scrollUp();
|
||||
|
||||
|
|
@ -636,7 +642,7 @@ export default {
|
|||
}
|
||||
|
||||
if (this.sortKey !== sortKey) {
|
||||
this.pageParams = getInitialPageParams(sortKey);
|
||||
this.pageParams = getInitialPageParams(this.pageSize);
|
||||
}
|
||||
this.sortKey = sortKey;
|
||||
|
||||
|
|
@ -676,6 +682,17 @@ export default {
|
|||
toggleBulkEditSidebar(showBulkEditSidebar) {
|
||||
this.showBulkEditSidebar = showBulkEditSidebar;
|
||||
},
|
||||
handlePageSizeChange(newPageSize) {
|
||||
/** make sure the page number is preserved so that the current context is not lost* */
|
||||
const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE);
|
||||
const pageNumberSize = lastPageSize ? 'lastPageSize' : 'firstPageSize';
|
||||
/** depending upon what page or page size we are dynamically set pageParams * */
|
||||
this.pageParams[pageNumberSize] = newPageSize;
|
||||
this.pageSize = newPageSize;
|
||||
scrollUp();
|
||||
|
||||
this.$router.push({ query: this.urlParams });
|
||||
},
|
||||
updateData(sortValue) {
|
||||
const firstPageSize = getParameterByName(PARAM_FIRST_PAGE_SIZE);
|
||||
const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE);
|
||||
|
|
@ -708,7 +725,7 @@ export default {
|
|||
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
|
||||
this.filterTokens = isSearchDisabled ? [] : getFilterTokens(window.location.search);
|
||||
this.pageParams = getInitialPageParams(
|
||||
sortKey,
|
||||
this.pageSize,
|
||||
isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined,
|
||||
isPositiveInteger(lastPageSize) ? parseInt(lastPageSize, 10) : undefined,
|
||||
pageAfter,
|
||||
|
|
@ -744,8 +761,10 @@ export default {
|
|||
:is-manual-ordering="isManualOrdering"
|
||||
:show-bulk-edit-sidebar="showBulkEditSidebar"
|
||||
:show-pagination-controls="showPaginationControls"
|
||||
:default-page-size="pageSize"
|
||||
sync-filter-and-sort
|
||||
use-keyset-pagination
|
||||
:show-page-size-change-controls="showPageSizeControls"
|
||||
:has-next-page="pageInfo.hasNextPage"
|
||||
:has-previous-page="pageInfo.hasPreviousPage"
|
||||
@click-tab="handleClickTab"
|
||||
|
|
@ -756,6 +775,7 @@ export default {
|
|||
@reorder="handleReorder"
|
||||
@sort="handleSort"
|
||||
@update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
>
|
||||
<template #nav-actions>
|
||||
<gl-button
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import {
|
|||
MILESTONE_DUE_DESC,
|
||||
NORMAL_FILTER,
|
||||
PAGE_SIZE,
|
||||
PAGE_SIZE_MANUAL,
|
||||
PARAM_ASSIGNEE_ID,
|
||||
POPULARITY_ASC,
|
||||
POPULARITY_DESC,
|
||||
|
|
@ -49,8 +48,8 @@ import {
|
|||
} from './constants';
|
||||
|
||||
export const getInitialPageParams = (
|
||||
sortKey,
|
||||
firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
|
||||
pageSize,
|
||||
firstPageSize = pageSize ?? PAGE_SIZE,
|
||||
lastPageSize,
|
||||
afterCursor,
|
||||
beforeCursor,
|
||||
|
|
|
|||
|
|
@ -1,280 +0,0 @@
|
|||
<script>
|
||||
import {
|
||||
GlSprintf,
|
||||
GlAlert,
|
||||
GlLink,
|
||||
GlDropdown,
|
||||
GlDropdownSectionHeader,
|
||||
GlDropdownItem,
|
||||
GlInfiniteScroll,
|
||||
} from '@gitlab/ui';
|
||||
import { throttle } from 'lodash';
|
||||
import { mapActions, mapState, mapGetters } from 'vuex';
|
||||
|
||||
import { timeRangeFromUrl } from '~/monitoring/utils';
|
||||
import { defaultTimeRange } from '~/vue_shared/constants';
|
||||
import { formatDate } from '../utils';
|
||||
import LogAdvancedFilters from './log_advanced_filters.vue';
|
||||
import LogControlButtons from './log_control_buttons.vue';
|
||||
import LogSimpleFilters from './log_simple_filters.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlSprintf,
|
||||
GlLink,
|
||||
GlAlert,
|
||||
GlDropdown,
|
||||
GlDropdownSectionHeader,
|
||||
GlDropdownItem,
|
||||
GlInfiniteScroll,
|
||||
LogSimpleFilters,
|
||||
LogAdvancedFilters,
|
||||
LogControlButtons,
|
||||
},
|
||||
props: {
|
||||
environmentName: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
currentPodName: {
|
||||
type: [String, null],
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
environmentsPath: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
clusterApplicationsDocumentationPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
clustersPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isElasticStackCalloutDismissed: false,
|
||||
scrollDownButtonDisabled: true,
|
||||
isDeprecationNoticeDismissed: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods']),
|
||||
...mapGetters('environmentLogs', ['trace', 'showAdvancedFilters']),
|
||||
|
||||
showLoader() {
|
||||
return this.logs.isLoading;
|
||||
},
|
||||
shouldShowElasticStackCallout() {
|
||||
return !(
|
||||
this.environments.isLoading ||
|
||||
this.isElasticStackCalloutDismissed ||
|
||||
this.showAdvancedFilters
|
||||
);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setInitData({
|
||||
timeRange: timeRangeFromUrl() || defaultTimeRange,
|
||||
environmentName: this.environmentName,
|
||||
podName: this.currentPodName,
|
||||
});
|
||||
|
||||
this.fetchEnvironments(this.environmentsPath);
|
||||
},
|
||||
methods: {
|
||||
...mapActions('environmentLogs', [
|
||||
'setInitData',
|
||||
'showEnvironment',
|
||||
'fetchEnvironments',
|
||||
'refreshPodLogs',
|
||||
'fetchMoreLogsPrepend',
|
||||
'dismissRequestEnvironmentsError',
|
||||
'dismissInvalidTimeRangeWarning',
|
||||
'dismissRequestLogsError',
|
||||
]),
|
||||
|
||||
isCurrentEnvironment(envName) {
|
||||
return envName === this.environments.current;
|
||||
},
|
||||
topReached() {
|
||||
if (!this.logs.isLoading) {
|
||||
this.fetchMoreLogsPrepend();
|
||||
}
|
||||
},
|
||||
scrollDown() {
|
||||
this.$refs.infiniteScroll.scrollDown();
|
||||
},
|
||||
scroll: throttle(function scrollThrottled({ target = {} }) {
|
||||
const { scrollTop = 0, clientHeight = 0, scrollHeight = 0 } = target;
|
||||
this.scrollDownButtonDisabled = scrollTop + clientHeight === scrollHeight;
|
||||
}, 200),
|
||||
formatDate,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="environment-logs-viewer d-flex flex-column py-3">
|
||||
<gl-alert
|
||||
v-if="shouldShowElasticStackCallout"
|
||||
ref="elasticsearchNotice"
|
||||
class="mb-3"
|
||||
@dismiss="isElasticStackCalloutDismissed = true"
|
||||
>
|
||||
{{
|
||||
s__(
|
||||
'Environments|Install Elastic Stack on your cluster to enable advanced querying capabilities such as full text search.',
|
||||
)
|
||||
}}
|
||||
<a :href="clusterApplicationsDocumentationPath">
|
||||
<strong>
|
||||
{{ __('View Documentation') }}
|
||||
</strong>
|
||||
</a>
|
||||
</gl-alert>
|
||||
<gl-alert
|
||||
v-if="environments.fetchError"
|
||||
class="mb-3"
|
||||
variant="danger"
|
||||
@dismiss="dismissRequestEnvironmentsError"
|
||||
>
|
||||
{{ s__('Metrics|There was an error fetching the environments data, please try again') }}
|
||||
</gl-alert>
|
||||
<gl-alert
|
||||
v-if="timeRange.invalidWarning"
|
||||
class="mb-3"
|
||||
variant="warning"
|
||||
@dismiss="dismissInvalidTimeRangeWarning"
|
||||
>
|
||||
{{ s__('Metrics|Invalid time range, please verify.') }}
|
||||
</gl-alert>
|
||||
<gl-alert
|
||||
v-if="!isDeprecationNoticeDismissed"
|
||||
:title="s__('Deprecations|Feature deprecation and removal')"
|
||||
class="mb-3"
|
||||
variant="danger"
|
||||
@dismiss="isDeprecationNoticeDismissed = true"
|
||||
>
|
||||
<gl-sprintf
|
||||
:message="
|
||||
s__(
|
||||
'Deprecations|The logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.',
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #epic="{ content }">
|
||||
<gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/7188" target="_blank">{{
|
||||
content
|
||||
}}</gl-link>
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
|
||||
<gl-sprintf
|
||||
:message="
|
||||
s__(
|
||||
'Deprecations|For information on a possible replacement %{epicStart} learn more about Opstrace %{epicEnd}.',
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #epic="{ content }">
|
||||
<gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/6976" target="_blank">{{
|
||||
content
|
||||
}}</gl-link>
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
</gl-alert>
|
||||
<gl-alert
|
||||
v-if="logs.fetchError"
|
||||
class="mb-3"
|
||||
variant="danger"
|
||||
@dismiss="dismissRequestLogsError"
|
||||
>
|
||||
{{ s__('Environments|There was an error fetching the logs. Please try again.') }}
|
||||
</gl-alert>
|
||||
|
||||
<div class="top-bar d-md-flex border bg-secondary-50 pt-2 pr-1 pb-0 pl-2">
|
||||
<div class="flex-grow-0">
|
||||
<gl-dropdown
|
||||
id="environments-dropdown"
|
||||
:text="environments.current"
|
||||
:disabled="environments.isLoading"
|
||||
class="gl-mr-3 gl-mb-3 gl-display-flex gl-md-display-block js-environments-dropdown"
|
||||
>
|
||||
<gl-dropdown-section-header>
|
||||
{{ s__('Environments|Environments') }}
|
||||
</gl-dropdown-section-header>
|
||||
<gl-dropdown-item
|
||||
v-for="env in environments.options"
|
||||
:key="env.id"
|
||||
:is-check-item="true"
|
||||
:is-checked="isCurrentEnvironment(env.name)"
|
||||
@click="showEnvironment(env.name)"
|
||||
>
|
||||
{{ env.name }}
|
||||
</gl-dropdown-item>
|
||||
</gl-dropdown>
|
||||
</div>
|
||||
|
||||
<log-advanced-filters
|
||||
v-if="showAdvancedFilters"
|
||||
ref="log-advanced-filters"
|
||||
class="d-md-flex flex-grow-1 min-width-0"
|
||||
:disabled="environments.isLoading"
|
||||
/>
|
||||
<log-simple-filters
|
||||
v-else
|
||||
ref="log-simple-filters"
|
||||
class="d-md-flex flex-grow-1 min-width-0"
|
||||
:disabled="environments.isLoading"
|
||||
/>
|
||||
|
||||
<log-control-buttons
|
||||
ref="scrollButtons"
|
||||
class="flex-grow-0 pr-2 mb-2 controllers gl-display-inline-flex"
|
||||
:scroll-down-button-disabled="scrollDownButtonDisabled"
|
||||
@refresh="refreshPodLogs()"
|
||||
@scrollDown="scrollDown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<gl-infinite-scroll
|
||||
ref="infiniteScroll"
|
||||
class="log-lines overflow-auto flex-grow-1 min-height-0"
|
||||
:fetched-items="logs.lines.length"
|
||||
@topReached="topReached"
|
||||
@scroll="scroll"
|
||||
>
|
||||
<template #items>
|
||||
<pre
|
||||
ref="logTrace"
|
||||
class="build-log"
|
||||
><code class="bash js-build-output"><div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>{{trace}}
|
||||
</code></pre>
|
||||
</template>
|
||||
<template #default><div></div></template>
|
||||
</gl-infinite-scroll>
|
||||
|
||||
<div ref="logFooter" class="py-2 px-3 text-white bg-secondary-900">
|
||||
<gl-sprintf :message="s__('Environments|Logs from %{start} to %{end}.')">
|
||||
<template #start>{{ formatDate(timeRange.current.start) }}</template>
|
||||
<template #end>{{ formatDate(timeRange.current.end) }}</template>
|
||||
</gl-sprintf>
|
||||
<gl-sprintf
|
||||
v-if="!logs.isComplete"
|
||||
:message="s__('Environments|Currently showing %{fetched} results.')"
|
||||
>
|
||||
<template #fetched>{{ logs.lines.length }}</template>
|
||||
</gl-sprintf>
|
||||
<template v-else> {{ s__('Environments|Currently showing all results.') }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
<script>
|
||||
import { GlFilteredSearch } from '@gitlab/ui';
|
||||
import { mapActions, mapState } from 'vuex';
|
||||
import { s__ } from '~/locale';
|
||||
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
|
||||
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
|
||||
import { timeRanges } from '~/vue_shared/constants';
|
||||
import { TOKEN_TYPE_POD_NAME } from '../constants';
|
||||
import TokenWithLoadingState from './tokens/token_with_loading_state.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlFilteredSearch,
|
||||
DateTimePicker,
|
||||
},
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
timeRanges,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState('environmentLogs', ['timeRange', 'pods', 'logs']),
|
||||
|
||||
timeRangeModel: {
|
||||
get() {
|
||||
return this.timeRange.selected;
|
||||
},
|
||||
set(val) {
|
||||
this.setTimeRange(val);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Token options.
|
||||
*
|
||||
* Returns null when no pods are present, so suggestions are displayed in the token
|
||||
*/
|
||||
podOptions() {
|
||||
if (this.pods.options.length) {
|
||||
return this.pods.options.map((podName) => ({ value: podName, title: podName }));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
tokens() {
|
||||
return [
|
||||
{
|
||||
icon: 'pod',
|
||||
type: TOKEN_TYPE_POD_NAME,
|
||||
title: s__('Environments|Pod name'),
|
||||
token: TokenWithLoadingState,
|
||||
operators: OPERATOR_IS_ONLY,
|
||||
unique: true,
|
||||
options: this.podOptions,
|
||||
loading: this.logs.isLoading,
|
||||
noOptionsText: s__('Environments|No pods to display'),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions('environmentLogs', ['showFilteredLogs', 'setTimeRange']),
|
||||
|
||||
filteredSearchSubmit(filters) {
|
||||
this.showFilteredLogs(filters);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-2 pr-2 flex-grow-1 min-width-0">
|
||||
<gl-filtered-search
|
||||
:placeholder="__('Search')"
|
||||
:clear-button-title="__('Clear')"
|
||||
:close-button-title="__('Close')"
|
||||
class="gl-h-32"
|
||||
:disabled="disabled || logs.isLoading"
|
||||
:available-tokens="tokens"
|
||||
@submit="filteredSearchSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<date-time-picker
|
||||
ref="dateTimePicker"
|
||||
v-model="timeRangeModel"
|
||||
:disabled="disabled"
|
||||
:options="timeRanges"
|
||||
class="mb-2 gl-h-32 pr-2 d-block date-time-picker-wrapper"
|
||||
right
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
export const dateFormatMask = 'mmm dd HH:MM:ss.l';
|
||||
|
||||
export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME';
|
||||
|
||||
export const tracking = {
|
||||
USED_SEARCH_BAR: 'used_search_bar',
|
||||
POD_LOG_CHANGED: 'pod_log_changed',
|
||||
TIME_RANGE_SET: 'time_range_set',
|
||||
ENVIRONMENT_SELECTED: 'environment_selected',
|
||||
REFRESH_POD_LOGS: 'refresh_pod_logs',
|
||||
MANAGED_APP_SELECTED: 'managed_app_selected',
|
||||
};
|
||||
|
||||
export const logExplorerOptions = {
|
||||
environments: 'environments',
|
||||
};
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
import { getParameterValues } from '~/lib/utils/url_utility';
|
||||
import LogViewer from './components/environment_logs.vue';
|
||||
import store from './stores';
|
||||
|
||||
export default (props = {}) => {
|
||||
const el = document.getElementById('environment-logs');
|
||||
const [currentPodName] = getParameterValues('pod_name');
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
new Vue({
|
||||
el,
|
||||
store,
|
||||
render(createElement) {
|
||||
return createElement(LogViewer, {
|
||||
props: {
|
||||
...el.dataset,
|
||||
currentPodName,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import Tracking from '~/tracking';
|
||||
|
||||
/**
|
||||
* The value of 1 in count, means there was one action performed
|
||||
* related to the tracked action, in either of the following categories
|
||||
* 1. Refreshing the logs
|
||||
* 2. Select an environment
|
||||
* 3. Change the time range
|
||||
* 4. Use the search bar
|
||||
*/
|
||||
const trackLogs = (label) =>
|
||||
Tracking.event(document.body.dataset.page, 'logs_view', {
|
||||
label,
|
||||
property: 'count',
|
||||
value: 1,
|
||||
});
|
||||
|
||||
export default trackLogs;
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
import axios from '~/lib/utils/axios_utils';
|
||||
import { backOff } from '~/lib/utils/common_utils';
|
||||
import { convertToFixedRange } from '~/lib/utils/datetime_range';
|
||||
import httpStatusCodes from '~/lib/utils/http_status';
|
||||
import { TOKEN_TYPE_POD_NAME, tracking, logExplorerOptions } from '../constants';
|
||||
import trackLogs from '../logs_tracking_helper';
|
||||
|
||||
import * as types from './mutation_types';
|
||||
|
||||
const requestUntilData = (url, params) =>
|
||||
backOff((next, stop) => {
|
||||
axios
|
||||
.get(url, { params })
|
||||
.then((res) => {
|
||||
if (res.status === httpStatusCodes.ACCEPTED) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
stop(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
stop(err);
|
||||
});
|
||||
});
|
||||
|
||||
const requestLogsUntilData = ({ commit, state }) => {
|
||||
const params = {};
|
||||
const type = logExplorerOptions.environments;
|
||||
const selectedObj = state[type].options.find(({ name }) => name === state[type].current);
|
||||
const path = selectedObj.logs_api_path;
|
||||
|
||||
if (state.pods.current) {
|
||||
params.pod_name = state.pods.current;
|
||||
}
|
||||
if (state.search) {
|
||||
params.search = state.search;
|
||||
}
|
||||
if (state.timeRange.current) {
|
||||
try {
|
||||
const { start, end } = convertToFixedRange(state.timeRange.current);
|
||||
params.start_time = start;
|
||||
params.end_time = end;
|
||||
} catch {
|
||||
commit(types.SHOW_TIME_RANGE_INVALID_WARNING);
|
||||
}
|
||||
}
|
||||
if (state.logs.cursor) {
|
||||
params.cursor = state.logs.cursor;
|
||||
}
|
||||
|
||||
return requestUntilData(path, params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts filters emitted by the component, e.g. a filterered-search
|
||||
* to parameters to be applied to the filters of the store
|
||||
* @param {Array} filters - List of strings or objects to filter by.
|
||||
* @returns {Object} - An object with `search` and `podName` keys.
|
||||
*/
|
||||
const filtersToParams = (filters = []) => {
|
||||
// Strings become part of the `search`
|
||||
const search = filters
|
||||
.filter((f) => typeof f === 'string')
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
// null podName to show all pods
|
||||
const podName = filters.find((f) => f?.type === TOKEN_TYPE_POD_NAME)?.value?.data ?? null;
|
||||
|
||||
return { search, podName };
|
||||
};
|
||||
|
||||
export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
|
||||
commit(types.SET_TIME_RANGE, timeRange);
|
||||
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
|
||||
commit(types.SET_CURRENT_POD_NAME, podName);
|
||||
};
|
||||
|
||||
export const showFilteredLogs = ({ dispatch, commit }, filters = []) => {
|
||||
const { podName, search } = filtersToParams(filters);
|
||||
|
||||
commit(types.SET_CURRENT_POD_NAME, podName);
|
||||
commit(types.SET_SEARCH, search);
|
||||
|
||||
dispatch('fetchLogs', tracking.USED_SEARCH_BAR);
|
||||
};
|
||||
|
||||
export const showPodLogs = ({ dispatch, commit }, podName) => {
|
||||
commit(types.SET_CURRENT_POD_NAME, podName);
|
||||
dispatch('fetchLogs', tracking.POD_LOG_CHANGED);
|
||||
};
|
||||
|
||||
export const setTimeRange = ({ dispatch, commit }, timeRange) => {
|
||||
commit(types.SET_TIME_RANGE, timeRange);
|
||||
dispatch('fetchLogs', tracking.TIME_RANGE_SET);
|
||||
};
|
||||
|
||||
export const showEnvironment = ({ dispatch, commit }, environmentName) => {
|
||||
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
|
||||
dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
|
||||
};
|
||||
|
||||
export const refreshPodLogs = ({ dispatch, commit }) => {
|
||||
commit(types.REFRESH_POD_LOGS);
|
||||
dispatch('fetchLogs', tracking.REFRESH_POD_LOGS);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch environments data and initial logs
|
||||
* @param {Object} store
|
||||
* @param {String} environmentsPath
|
||||
*/
|
||||
export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
|
||||
commit(types.REQUEST_ENVIRONMENTS_DATA);
|
||||
|
||||
return axios
|
||||
.get(environmentsPath)
|
||||
.then(({ data }) => {
|
||||
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data);
|
||||
dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
|
||||
})
|
||||
.catch(() => {
|
||||
commit(types.RECEIVE_ENVIRONMENTS_DATA_ERROR);
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchLogs = ({ commit, state }, trackingLabel) => {
|
||||
commit(types.REQUEST_LOGS_DATA);
|
||||
|
||||
return requestLogsUntilData({ commit, state })
|
||||
.then(({ data }) => {
|
||||
const { pod_name, pods, logs, cursor } = data;
|
||||
if (logs && logs.length > 0) {
|
||||
trackLogs(trackingLabel);
|
||||
}
|
||||
commit(types.RECEIVE_LOGS_DATA_SUCCESS, { logs, cursor });
|
||||
commit(types.SET_CURRENT_POD_NAME, pod_name);
|
||||
commit(types.RECEIVE_PODS_DATA_SUCCESS, pods);
|
||||
})
|
||||
.catch(() => {
|
||||
commit(types.RECEIVE_PODS_DATA_ERROR);
|
||||
commit(types.RECEIVE_LOGS_DATA_ERROR);
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchMoreLogsPrepend = ({ commit, state }) => {
|
||||
if (state.logs.isComplete) {
|
||||
// return when all logs are loaded
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
commit(types.REQUEST_LOGS_DATA_PREPEND);
|
||||
|
||||
return requestLogsUntilData({ commit, state })
|
||||
.then(({ data }) => {
|
||||
const { logs, cursor } = data;
|
||||
commit(types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS, { logs, cursor });
|
||||
})
|
||||
.catch(() => {
|
||||
commit(types.RECEIVE_LOGS_DATA_PREPEND_ERROR);
|
||||
});
|
||||
};
|
||||
|
||||
export const dismissRequestEnvironmentsError = ({ commit }) => {
|
||||
commit(types.HIDE_REQUEST_ENVIRONMENTS_ERROR);
|
||||
};
|
||||
|
||||
export const dismissRequestLogsError = ({ commit }) => {
|
||||
commit(types.HIDE_REQUEST_LOGS_ERROR);
|
||||
};
|
||||
|
||||
export const dismissInvalidTimeRangeWarning = ({ commit }) => {
|
||||
commit(types.HIDE_TIME_RANGE_INVALID_WARNING);
|
||||
};
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { formatDate } from '../utils';
|
||||
|
||||
const mapTrace = ({ timestamp = null, pod = '', message = '' }) =>
|
||||
[timestamp ? formatDate(timestamp) : '', pod, message].join(' | ');
|
||||
|
||||
export const trace = (state) => state.logs.lines.map(mapTrace).join('\n');
|
||||
|
||||
export const showAdvancedFilters = (state) => {
|
||||
const environment = state.environments.options.find(
|
||||
({ name }) => name === state.environments.current,
|
||||
);
|
||||
|
||||
return Boolean(environment?.enable_advanced_logs_querying);
|
||||
};
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import * as actions from './actions';
|
||||
import * as getters from './getters';
|
||||
import mutations from './mutations';
|
||||
import state from './state';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
export const createStore = () =>
|
||||
new Vuex.Store({
|
||||
modules: {
|
||||
environmentLogs: {
|
||||
namespaced: true,
|
||||
actions,
|
||||
mutations,
|
||||
state: state(),
|
||||
getters,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default createStore;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import dateFormat from 'dateformat';
|
||||
import { dateFormatMask } from './constants';
|
||||
|
||||
export const formatDate = (timestamp) => dateFormat(timestamp, dateFormatMask);
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<script>
|
||||
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
|
||||
import { s__, sprintf } from '~/locale';
|
||||
|
||||
export const PAGE_SIZES = [20, 50, 100];
|
||||
|
||||
export default {
|
||||
components: { GlDropdown, GlDropdownItem },
|
||||
props: {
|
||||
value: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
emitInput(pageSize) {
|
||||
this.$emit('input', pageSize);
|
||||
},
|
||||
getPageSizeText(pageSize) {
|
||||
return sprintf(s__('SecurityReports|Show %{pageSize} items'), { pageSize });
|
||||
},
|
||||
},
|
||||
PAGE_SIZES,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<gl-dropdown :text="getPageSizeText(value)" right menu-class="gl-w-auto! gl-min-w-0">
|
||||
<gl-dropdown-item
|
||||
v-for="pageSize in $options.PAGE_SIZES"
|
||||
:key="pageSize"
|
||||
@click="emitInput(pageSize)"
|
||||
>
|
||||
<span class="gl-white-space-nowrap">{{ getPageSizeText(pageSize) }}</span>
|
||||
</gl-dropdown-item>
|
||||
</gl-dropdown>
|
||||
</template>
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
<script>
|
||||
import { GlAlert, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui';
|
||||
import { uniqueId } from 'lodash';
|
||||
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
|
||||
import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue';
|
||||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
|
||||
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
|
||||
|
||||
import { DEFAULT_SKELETON_COUNT } from '../constants';
|
||||
import { DEFAULT_SKELETON_COUNT, PAGE_SIZE_STORAGE_KEY } from '../constants';
|
||||
import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
|
||||
import IssuableItem from './issuable_item.vue';
|
||||
import IssuableTabs from './issuable_tabs.vue';
|
||||
|
|
@ -29,6 +31,8 @@ export default {
|
|||
IssuableBulkEditSidebar,
|
||||
GlPagination,
|
||||
VueDraggable,
|
||||
PageSizeSelector,
|
||||
LocalStorageSync,
|
||||
},
|
||||
props: {
|
||||
namespace: {
|
||||
|
|
@ -173,6 +177,11 @@ export default {
|
|||
required: false,
|
||||
default: false,
|
||||
},
|
||||
showPageSizeChangeControls: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -262,7 +271,11 @@ export default {
|
|||
handleVueDraggableUpdate({ newIndex, oldIndex }) {
|
||||
this.$emit('reorder', { newIndex, oldIndex });
|
||||
},
|
||||
handlePageSizeChange(newPageSize) {
|
||||
this.$emit('page-size-change', newPageSize);
|
||||
},
|
||||
},
|
||||
PAGE_SIZE_STORAGE_KEY,
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
@ -353,24 +366,38 @@ export default {
|
|||
<slot v-else name="empty-state"></slot>
|
||||
</template>
|
||||
|
||||
<div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3">
|
||||
<div class="gl-text-center gl-mt-6 gl-relative">
|
||||
<gl-keyset-pagination
|
||||
v-if="showPaginationControls && useKeysetPagination"
|
||||
:has-next-page="hasNextPage"
|
||||
:has-previous-page="hasPreviousPage"
|
||||
@next="$emit('next-page')"
|
||||
@prev="$emit('previous-page')"
|
||||
/>
|
||||
<gl-pagination
|
||||
v-else-if="showPaginationControls"
|
||||
:per-page="defaultPageSize"
|
||||
:total-items="totalItems"
|
||||
:value="currentPage"
|
||||
:prev-page="previousPage"
|
||||
:next-page="nextPage"
|
||||
align="center"
|
||||
class="gl-pagination gl-mt-3"
|
||||
@input="$emit('page-change', $event)"
|
||||
/>
|
||||
|
||||
<local-storage-sync
|
||||
v-if="showPageSizeChangeControls"
|
||||
:value="defaultPageSize"
|
||||
:storage-key="$options.PAGE_SIZE_STORAGE_KEY"
|
||||
@input="handlePageSizeChange"
|
||||
>
|
||||
<page-size-selector
|
||||
:value="defaultPageSize"
|
||||
class="gl-absolute gl-right-0"
|
||||
@input="handlePageSizeChange"
|
||||
/>
|
||||
</local-storage-sync>
|
||||
</div>
|
||||
<gl-pagination
|
||||
v-else-if="showPaginationControls"
|
||||
:per-page="defaultPageSize"
|
||||
:total-items="totalItems"
|
||||
:value="currentPage"
|
||||
:prev-page="previousPage"
|
||||
:next-page="nextPage"
|
||||
align="center"
|
||||
class="gl-pagination gl-mt-3"
|
||||
@input="$emit('page-change', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -56,3 +56,5 @@ export const IssuableTypes = {
|
|||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export const DEFAULT_SKELETON_COUNT = 5;
|
||||
|
||||
export const PAGE_SIZE_STORAGE_KEY = 'issuable_list_page_size';
|
||||
|
|
|
|||
|
|
@ -64,10 +64,6 @@
|
|||
border-color: var(--ide-input-border, $gray-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
a.gl-tab-nav-item-active {
|
||||
box-shadow: inset 0 -2px 0 0 var(--ide-input-border, $gray-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ $search-sidebar-min-width: 240px;
|
|||
$search-sidebar-max-width: 300px;
|
||||
$search-input-field-x-min-width: 200px;
|
||||
$search-input-field-min-width: 320px;
|
||||
$search-input-field-max-width: 600px;
|
||||
$search-input-field-max-width: 640px;
|
||||
$search-keyboard-shortcut: '/';
|
||||
|
||||
$border-radius-medium: 3px;
|
||||
|
||||
|
|
@ -67,54 +68,58 @@ input[type='checkbox']:hover {
|
|||
}
|
||||
}
|
||||
|
||||
// This is a temporary workaround!
|
||||
// the button in GitLab UI Search components need to be updated to not be the small size
|
||||
// see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540
|
||||
.header-search .gl-search-box-by-type-clear.btn-sm {
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
|
||||
.header-search {
|
||||
min-width: $search-input-field-min-width;
|
||||
|
||||
// This is a temporary workaround!
|
||||
// the button in GitLab UI Search components need to be updated to not be the small size
|
||||
// see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540
|
||||
.gl-search-box-by-type-clear.btn-sm {
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
|
||||
@include media-breakpoint-between(md, lg) {
|
||||
min-width: $search-input-field-x-min-width;
|
||||
}
|
||||
|
||||
input,
|
||||
svg {
|
||||
transition: border-color ease-in-out $default-transition-duration,
|
||||
background-color ease-in-out $default-transition-duration;
|
||||
&.is-active {
|
||||
&.is-searching {
|
||||
.in-search-scope-help {
|
||||
position: absolute;
|
||||
top: $gl-spacing-scale-2;
|
||||
right: 2.125rem;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-not-searching {
|
||||
.in-search-scope-help {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-not-active {
|
||||
.btn.gl-clear-icon-button {
|
||||
.btn.gl-clear-icon-button,
|
||||
.in-search-scope-help {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '/';
|
||||
display: inline-block;
|
||||
content: $search-keyboard-shortcut;
|
||||
transform: translateY(calc(50% - #{$gl-spacing-scale-2}));
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 8px;
|
||||
transform: translateY(calc(50% - 4px));
|
||||
padding: 4px 5px;
|
||||
right: $gl-spacing-scale-3;
|
||||
padding: $gl-spacing-scale-2 5px;
|
||||
font-size: $gl-font-size-small;
|
||||
font-family: $monospace-font;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-image: none;
|
||||
border-radius: $border-radius-medium;
|
||||
box-shadow: none;
|
||||
white-space: pre-wrap;
|
||||
box-sizing: border-box;
|
||||
// Safari
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: keep-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1504,7 +1504,7 @@ svg.s16 {
|
|||
vertical-align: -3px;
|
||||
}
|
||||
.header-content .header-search-new {
|
||||
max-width: 600px;
|
||||
max-width: 640px;
|
||||
}
|
||||
.header-search {
|
||||
min-width: 320px;
|
||||
|
|
@ -1516,27 +1516,20 @@ svg.s16 {
|
|||
}
|
||||
.header-search.is-not-active::after {
|
||||
content: "/";
|
||||
display: inline-block;
|
||||
transform: translateY(calc(50% - 0.25rem));
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 8px;
|
||||
transform: translateY(calc(50% - 4px));
|
||||
padding: 4px 5px;
|
||||
right: 0.5rem;
|
||||
padding: 0.25rem 5px;
|
||||
font-size: 12px;
|
||||
font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",
|
||||
"Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-image: none;
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
white-space: pre-wrap;
|
||||
box-sizing: border-box;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: keep-all;
|
||||
}
|
||||
.search {
|
||||
margin: 0 8px;
|
||||
|
|
|
|||
|
|
@ -1489,7 +1489,7 @@ svg.s16 {
|
|||
vertical-align: -3px;
|
||||
}
|
||||
.header-content .header-search-new {
|
||||
max-width: 600px;
|
||||
max-width: 640px;
|
||||
}
|
||||
.header-search {
|
||||
min-width: 320px;
|
||||
|
|
@ -1501,27 +1501,20 @@ svg.s16 {
|
|||
}
|
||||
.header-search.is-not-active::after {
|
||||
content: "/";
|
||||
display: inline-block;
|
||||
transform: translateY(calc(50% - 0.25rem));
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 8px;
|
||||
transform: translateY(calc(50% - 4px));
|
||||
padding: 4px 5px;
|
||||
right: 0.5rem;
|
||||
padding: 0.25rem 5px;
|
||||
font-size: 12px;
|
||||
font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",
|
||||
"Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-image: none;
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
white-space: pre-wrap;
|
||||
box-sizing: border-box;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: keep-all;
|
||||
}
|
||||
.search {
|
||||
margin: 0 8px;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
|
|||
push_frontend_feature_flag(:issue_assignees_widget, @project)
|
||||
push_frontend_feature_flag(:realtime_labels, project)
|
||||
push_frontend_feature_flag(:refactor_security_extension, @project)
|
||||
push_frontend_feature_flag(:refactor_code_quality_inline_findings, project)
|
||||
push_frontend_feature_flag(:mr_attention_requests, current_user)
|
||||
push_frontend_feature_flag(:moved_mr_sidebar, project)
|
||||
push_frontend_feature_flag(:paginated_mr_discussions, project)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ module Projects
|
|||
'type' => 'object',
|
||||
'properties' => {
|
||||
'project_id' => { 'type' => 'integer' },
|
||||
'namespace_id' => { 'type' => 'integer' }
|
||||
'namespace_id' => { 'type' => 'integer' },
|
||||
'root_namespace_id' => { 'type' => 'integer' }
|
||||
},
|
||||
'required' => %w[project_id namespace_id]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ module SearchHelper
|
|||
# search_context exposes a bit too much data to the frontend, this controls what data we share and when.
|
||||
def header_search_context
|
||||
{}.tap do |hash|
|
||||
hash[:group] = { id: search_context.group.id, name: search_context.group.name } if search_context.for_group?
|
||||
hash[:group] = { id: search_context.group.id, name: search_context.group.name, full_name: search_context.group.full_name } if search_context.for_group?
|
||||
hash[:group_metadata] = search_context.group_metadata if search_context.for_group?
|
||||
|
||||
hash[:project] = { id: search_context.project.id, name: search_context.project.name } if search_context.for_project?
|
||||
|
|
|
|||
|
|
@ -36,8 +36,10 @@ module Issues
|
|||
end
|
||||
|
||||
def branches_with_iid_of(issue)
|
||||
project.repository.branch_names.select do |branch|
|
||||
branch =~ /\A#{issue.iid}-(?!\d+-stable)/i
|
||||
branch_name_regex = /\A#{issue.iid}-(?!\d+-stable)/i
|
||||
|
||||
project.repository.search_branch_names("#{issue.iid}-*").select do |branch|
|
||||
branch.match?(branch_name_regex)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ module MergeRequests
|
|||
mark_pending_todos_as_done(merge_request)
|
||||
execute_approval_hooks(merge_request, current_user)
|
||||
remove_attention_requested(merge_request)
|
||||
merge_request_activity_counter.track_approve_mr_action(user: current_user)
|
||||
merge_request_activity_counter.track_approve_mr_action(user: current_user, merge_request: merge_request)
|
||||
|
||||
success
|
||||
end
|
||||
|
|
|
|||
|
|
@ -263,8 +263,12 @@ module Projects
|
|||
end
|
||||
|
||||
def publish_project_deleted_event_for(project)
|
||||
data = { project_id: project.id, namespace_id: project.namespace_id }
|
||||
event = Projects::ProjectDeletedEvent.new(data: data)
|
||||
event = Projects::ProjectDeletedEvent.new(data: {
|
||||
project_id: project.id,
|
||||
namespace_id: project.namespace_id,
|
||||
root_namespace_id: project.root_namespace.id
|
||||
})
|
||||
|
||||
Gitlab::EventStore.publish(event)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -53,12 +53,8 @@ module BulkImports
|
|||
pipeline_tracker.update!(status_event: 'start', jid: jid)
|
||||
pipeline_tracker.pipeline_class.new(context).run
|
||||
pipeline_tracker.finish!
|
||||
rescue BulkImports::NetworkError => e
|
||||
if e.retriable?(pipeline_tracker)
|
||||
retry_tracker(e)
|
||||
else
|
||||
fail_tracker(e)
|
||||
end
|
||||
rescue BulkImports::RetryPipelineError => e
|
||||
retry_tracker(e)
|
||||
rescue StandardError => e
|
||||
fail_tracker(e)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
description: Merge request approvals
|
||||
category: merge_requests
|
||||
action: i_code_review_user_approve_mr
|
||||
label_description:
|
||||
property_description:
|
||||
value_description:
|
||||
extra_properties:
|
||||
identifiers:
|
||||
- project
|
||||
- user
|
||||
- namespace
|
||||
product_section: 'TBD'
|
||||
product_stage: create
|
||||
product_group: code_review
|
||||
product_category: code_review
|
||||
milestone: "15.2"
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91493
|
||||
distributions:
|
||||
- ce
|
||||
- ee
|
||||
tiers:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: refactor_code_quality_inline_findings
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88576
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364198
|
||||
milestone: '15.1'
|
||||
type: development
|
||||
group: group::static analysis
|
||||
default_enabled: false
|
||||
|
|
@ -13,29 +13,25 @@ disable or enable this feature manually by following
|
|||
[these instructions](#disabling-or-enabling-the-automatic-background-verification).
|
||||
|
||||
Automatic background verification ensures that the transferred data matches a
|
||||
calculated checksum. If the checksum of the data on the **primary** node matches checksum of the
|
||||
data on the **secondary** node, the data transferred successfully. Following a planned failover,
|
||||
calculated checksum. If the checksum of the data on the **primary** site matches checksum of the
|
||||
data on the **secondary** site, the data transferred successfully. Following a planned failover,
|
||||
any corrupted data may be **lost**, depending on the extent of the corruption.
|
||||
|
||||
If verification fails on the **primary** node, this indicates Geo is replicating a corrupted object.
|
||||
You can restore it from backup or remove it from the **primary** node to resolve the issue.
|
||||
If verification fails on the **primary** site, this indicates Geo is replicating a corrupted object.
|
||||
You can restore it from backup or remove it from the **primary** site to resolve the issue.
|
||||
|
||||
If verification succeeds on the **primary** node but fails on the **secondary** node,
|
||||
If verification succeeds on the **primary** site but fails on the **secondary** site,
|
||||
this indicates that the object was corrupted during the replication process.
|
||||
Geo actively try to correct verification failures marking the repository to
|
||||
be resynced with a back-off period. If you want to reset the verification for
|
||||
these failures, so you should follow [these instructions](background_verification.md#reset-verification-for-projects-where-verification-has-failed).
|
||||
|
||||
If verification is lagging significantly behind replication, consider giving
|
||||
the node more time before scheduling a planned failover.
|
||||
the site more time before scheduling a planned failover.
|
||||
|
||||
## Disabling or enabling the automatic background verification
|
||||
|
||||
Run the following commands in a Rails console on the **primary** node:
|
||||
|
||||
```shell
|
||||
gitlab-rails console
|
||||
```
|
||||
Run the following commands in a [Rails console](../../operations/rails_console.md) on a **Rails node on the primary** site.
|
||||
|
||||
To check if automatic background verification is enabled:
|
||||
|
||||
|
|
@ -57,33 +53,33 @@ Feature.enable('geo_repository_verification')
|
|||
|
||||
## Repository verification
|
||||
|
||||
On the **primary** node:
|
||||
On the **primary** site:
|
||||
|
||||
1. On the top bar, select **Menu > Admin**.
|
||||
1. On the left sidebar, select **Geo > Nodes**.
|
||||
1. Expand **Verification information** tab for that node to view automatic checksumming
|
||||
1. On the left sidebar, select **Geo > Sites**.
|
||||
1. Expand **Verification information** tab for that site to view automatic checksumming
|
||||
status for repositories and wikis. Successes are shown in green, pending work
|
||||
in gray, and failures in red.
|
||||
|
||||

|
||||
|
||||
On the **secondary** node:
|
||||
On the **secondary** site:
|
||||
|
||||
1. On the top bar, select **Menu > Admin**.
|
||||
1. On the left sidebar, select **Geo > Nodes**.
|
||||
1. Expand **Verification information** tab for that node to view automatic checksumming
|
||||
1. On the left sidebar, select **Geo > Sites**.
|
||||
1. Expand **Verification information** tab for that site to view automatic checksumming
|
||||
status for repositories and wikis. Successes are shown in green, pending work
|
||||
in gray, and failures in red.
|
||||
|
||||

|
||||
|
||||
## Using checksums to compare Geo nodes
|
||||
## Using checksums to compare Geo sites
|
||||
|
||||
To check the health of Geo **secondary** nodes, we use a checksum over the list of
|
||||
To check the health of Geo **secondary** sites, we use a checksum over the list of
|
||||
Git references and their values. The checksum includes `HEAD`, `heads`, `tags`,
|
||||
`notes`, and GitLab-specific references to ensure true consistency. If two nodes
|
||||
`notes`, and GitLab-specific references to ensure true consistency. If two sites
|
||||
have the same checksum, then they definitely hold the same references. We compute
|
||||
the checksum for every node after every update to make sure that they are all
|
||||
the checksum for every site after every update to make sure that they are all
|
||||
in sync.
|
||||
|
||||
## Repository re-verification
|
||||
|
|
@ -95,22 +91,17 @@ data. The default and recommended re-verification interval is 7 days, though
|
|||
an interval as short as 1 day can be set. Shorter intervals reduce risk but
|
||||
increase load and vice versa.
|
||||
|
||||
On the **primary** node:
|
||||
On the **primary** site:
|
||||
|
||||
1. On the top bar, select **Menu > Admin**.
|
||||
1. On the left sidebar, select **Geo > Nodes**.
|
||||
1. Select **Edit** for the **primary** node to customize the minimum
|
||||
1. On the left sidebar, select **Geo > Sites**.
|
||||
1. Select **Edit** for the **primary** site to customize the minimum
|
||||
re-verification interval:
|
||||
|
||||

|
||||
|
||||
The automatic background re-verification is enabled by default, but you can
|
||||
disable if you need. Run the following commands in a Rails console on the
|
||||
**primary** node:
|
||||
|
||||
```shell
|
||||
gitlab-rails console
|
||||
```
|
||||
disable if you need. Run the following commands in a [Rails console](../../operations/rails_console.md) on a **Rails node on the primary** site:
|
||||
|
||||
To disable automatic background re-verification:
|
||||
|
||||
|
|
@ -126,11 +117,13 @@ Feature.enable('geo_repository_reverification')
|
|||
|
||||
## Reset verification for projects where verification has failed
|
||||
|
||||
Geo actively try to correct verification failures marking the repository to
|
||||
Geo actively tries to correct verification failures marking the repository to
|
||||
be resynced with a back-off period. If you want to reset them manually, this
|
||||
Rake task marks projects where verification has failed or the checksum mismatch
|
||||
to be resynced without the back-off period:
|
||||
|
||||
Run the appropriate commands on a **Rails node on the primary** site.
|
||||
|
||||
For repositories:
|
||||
|
||||
```shell
|
||||
|
|
@ -145,9 +138,9 @@ sudo gitlab-rake geo:verification:wiki:reset
|
|||
|
||||
## Reconcile differences with checksum mismatches
|
||||
|
||||
If the **primary** and **secondary** nodes have a checksum verification mismatch, the cause may not be apparent. To find the cause of a checksum mismatch:
|
||||
If the **primary** and **secondary** sites have a checksum verification mismatch, the cause may not be apparent. To find the cause of a checksum mismatch:
|
||||
|
||||
1. On the **primary** node:
|
||||
1. On the **primary** site:
|
||||
1. On the top bar, select **Menu > Admin**.
|
||||
1. On the left sidebar, select **Overview > Projects**.
|
||||
1. Find the project that you want to check the checksum differences and
|
||||
|
|
@ -157,31 +150,32 @@ If the **primary** and **secondary** nodes have a checksum verification mismatch
|
|||
|
||||

|
||||
|
||||
1. Go to the project's repository directory on both **primary** and **secondary** nodes
|
||||
(the path is usually `/var/opt/gitlab/git-data/repositories`). If `git_data_dirs`
|
||||
1. On a **Gitaly node on the primary** site and a **Gitaly node on the secondary** site, go to the project's repository directory. If using Gitaly Cluster, [check that it is in a healthy state](../../gitaly/troubleshooting.md#check-cluster-health) prior to running these commands.
|
||||
|
||||
The default path is `/var/opt/gitlab/git-data/repositories`. If `git_data_dirs`
|
||||
is customized, check the directory layout on your server to be sure:
|
||||
|
||||
```shell
|
||||
cd /var/opt/gitlab/git-data/repositories
|
||||
```
|
||||
|
||||
1. Run the following command on the **primary** site, redirecting the output to a file:
|
||||
|
||||
1. Run the following command on the **primary** node, redirecting the output to a file:
|
||||
```shell
|
||||
git show-ref --head | grep -E "HEAD|(refs/(heads|tags|keep-around|merge-requests|environments|notes)/)" > primary-site-refs
|
||||
```
|
||||
|
||||
```shell
|
||||
git show-ref --head | grep -E "HEAD|(refs/(heads|tags|keep-around|merge-requests|environments|notes)/)" > primary-node-refs
|
||||
```
|
||||
1. Run the following command on the **secondary** site, redirecting the output to a file:
|
||||
|
||||
1. Run the following command on the **secondary** node, redirecting the output to a file:
|
||||
```shell
|
||||
git show-ref --head | grep -E "HEAD|(refs/(heads|tags|keep-around|merge-requests|environments|notes)/)" > secondary-site-refs
|
||||
```
|
||||
|
||||
```shell
|
||||
git show-ref --head | grep -E "HEAD|(refs/(heads|tags|keep-around|merge-requests|environments|notes)/)" > secondary-node-refs
|
||||
```
|
||||
1. Copy the files from the previous steps on the same system, and do a diff between the contents:
|
||||
|
||||
1. Copy the files from the previous steps on the same system, and do a diff between the contents:
|
||||
|
||||
```shell
|
||||
diff primary-node-refs secondary-node-refs
|
||||
```
|
||||
```shell
|
||||
diff primary-site-refs secondary-site-refs
|
||||
```
|
||||
|
||||
## Current limitations
|
||||
|
||||
|
|
@ -191,10 +185,10 @@ progress to include them in [Geo: Verify all replicated data](https://gitlab.com
|
|||
|
||||
For now, you can verify their integrity
|
||||
manually by following [these instructions](../../raketasks/check.md) on both
|
||||
nodes, and comparing the output between them.
|
||||
sites, and comparing the output between them.
|
||||
|
||||
In GitLab EE 12.1, Geo calculates checksums for attachments, LFS objects, and
|
||||
archived traces on secondary nodes after the transfer, compares it with the
|
||||
archived traces on secondary sites after the transfer, compares it with the
|
||||
stored checksums, and rejects transfers if mismatched. Geo
|
||||
currently does not support an automatic way to verify these data if they have
|
||||
been synced before GitLab EE 12.1.
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ See [Review Apps](../review_apps.md) for more details about Review Apps.
|
|||
|
||||
To run tests in parallel on CI, the [Knapsack](https://github.com/KnapsackPro/knapsack)
|
||||
gem is used. Knapsack reports are generated automatically and stored in the `GCS` bucket
|
||||
`knapsack-reports` in the `gitlab-qa-resources` project. The [`KnapsackReport`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/qa/qa/tools/knapsack_report.rb)
|
||||
`knapsack-reports` in the `gitlab-qa-resources` project. The [`KnapsackReport`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/qa/qa/support/knapsack_report.rb)
|
||||
helper handles automated report generation and upload.
|
||||
|
||||
## Test metrics
|
||||
|
|
|
|||
|
|
@ -56,9 +56,10 @@ usage data monthly.
|
|||
To submit the data, [export your license usage](../../subscriptions/self_managed/index.md#export-your-license-usage)
|
||||
and send it by email to the renewals service, `renewals-service@customers.gitlab.com`.
|
||||
|
||||
If you don't submit your data each month after your subscription start date, a banner displays to remind you to
|
||||
submit your data. The banner displays in the **Admin Area** on the **Dashboard** and on the **Subscription**
|
||||
pages. You can only dismiss it until the following month after you submit your license usage data.
|
||||
If you don't submit your data each month after your subscription start date, an email is sent to the address
|
||||
associated with your subscription and a banner displays to remind you to submit your data. The banner displays
|
||||
in the **Admin Area** on the **Dashboard** and on the **Subscription** pages. You can only dismiss it until the
|
||||
following month after you submit your license usage data.
|
||||
|
||||
## What happens when your license expires
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ module BulkImports
|
|||
RETRIABLE_EXCEPTIONS = Gitlab::HTTP::HTTP_TIMEOUT_ERRORS
|
||||
RETRIABLE_HTTP_CODES = [429].freeze
|
||||
|
||||
DEFAULT_RETRY_DELAY_SECONDS = 60
|
||||
DEFAULT_RETRY_DELAY_SECONDS = 30
|
||||
|
||||
MAX_RETRIABLE_COUNT = 3
|
||||
MAX_RETRIABLE_COUNT = 10
|
||||
|
||||
def initialize(message = nil, response: nil)
|
||||
raise ArgumentError, 'message or response required' if message.blank? && response.blank?
|
||||
|
|
|
|||
|
|
@ -56,12 +56,16 @@ module BulkImports
|
|||
pipeline_step: step,
|
||||
step_class: class_name
|
||||
)
|
||||
rescue BulkImports::NetworkError => e
|
||||
if e.retriable?(context.tracker)
|
||||
raise BulkImports::RetryPipelineError.new(e.message, e.retry_delay)
|
||||
else
|
||||
log_and_fail(e, step)
|
||||
end
|
||||
rescue BulkImports::RetryPipelineError
|
||||
raise
|
||||
rescue StandardError => e
|
||||
log_import_failure(e, step)
|
||||
|
||||
mark_as_failed if abort_on_failure?
|
||||
|
||||
nil
|
||||
log_and_fail(e, step)
|
||||
end
|
||||
|
||||
def extracted_data_from
|
||||
|
|
@ -74,11 +78,17 @@ module BulkImports
|
|||
run if extracted_data.has_next_page?
|
||||
end
|
||||
|
||||
def mark_as_failed
|
||||
warn(message: 'Pipeline failed')
|
||||
def log_and_fail(exception, step)
|
||||
log_import_failure(exception, step)
|
||||
|
||||
context.entity.fail_op!
|
||||
tracker.fail_op!
|
||||
|
||||
if abort_on_failure?
|
||||
warn(message: 'Aborting entity migration due to pipeline failure')
|
||||
context.entity.fail_op!
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def skip!(message, extra = {})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module BulkImports
|
||||
class RetryPipelineError < Error
|
||||
attr_reader :retry_delay
|
||||
|
||||
def initialize(message, retry_delay)
|
||||
super(message)
|
||||
|
||||
@retry_delay = retry_delay
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -76,8 +76,19 @@ module Gitlab
|
|||
track_unique_action_by_user(MR_REOPEN_ACTION, user)
|
||||
end
|
||||
|
||||
def track_approve_mr_action(user:)
|
||||
def track_approve_mr_action(user:, merge_request:)
|
||||
track_unique_action_by_user(MR_APPROVE_ACTION, user)
|
||||
|
||||
project = merge_request.target_project
|
||||
return unless Feature.enabled?(:route_hll_to_snowplow_phase2, project.namespace)
|
||||
|
||||
Gitlab::Tracking.event(
|
||||
'merge_requests',
|
||||
MR_APPROVE_ACTION,
|
||||
project: project,
|
||||
namespace: project.namespace,
|
||||
user: user
|
||||
)
|
||||
end
|
||||
|
||||
def track_unapprove_mr_action(user:)
|
||||
|
|
|
|||
|
|
@ -938,9 +938,6 @@ msgstr ""
|
|||
msgid "%{scope} results for term '%{term}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{search} %{description} %{scope}"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{seconds}s"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -1355,6 +1352,11 @@ msgstr ""
|
|||
msgid "0 bytes"
|
||||
msgstr ""
|
||||
|
||||
msgid "1 Code quality finding"
|
||||
msgid_plural "%d Code quality findings"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "1 Day"
|
||||
msgid_plural "%d Days"
|
||||
msgstr[0] ""
|
||||
|
|
@ -12988,9 +12990,6 @@ msgstr ""
|
|||
msgid "Deprecations|The logs and tracing features were also deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0."
|
||||
msgstr ""
|
||||
|
||||
msgid "Deprecations|The logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0."
|
||||
msgstr ""
|
||||
|
||||
msgid "Deprecations|The metrics feature was deprecated in GitLab 14.7."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -13080,9 +13079,6 @@ msgstr ""
|
|||
msgid "DesignManagement|Comment"
|
||||
msgstr ""
|
||||
|
||||
msgid "DesignManagement|Comments you resolve can be viewed and unresolved by going to the \"Resolved Comments\" section below"
|
||||
msgstr ""
|
||||
|
||||
msgid "DesignManagement|Could not add a new comment. Please try again."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -13125,9 +13121,6 @@ msgstr ""
|
|||
msgid "DesignManagement|Keep comment"
|
||||
msgstr ""
|
||||
|
||||
msgid "DesignManagement|Learn more about resolving comments"
|
||||
msgstr ""
|
||||
|
||||
msgid "DesignManagement|Requested design version does not exist. Showing latest version instead"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -14615,12 +14608,6 @@ msgstr ""
|
|||
msgid "Environments|Commit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|Currently showing %{fetched} results."
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|Currently showing all results."
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|Delete"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -14657,9 +14644,6 @@ msgstr ""
|
|||
msgid "Environments|How do I create an environment?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|Install Elastic Stack on your cluster to enable advanced querying capabilities such as full text search."
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|Job"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -14669,9 +14653,6 @@ msgstr ""
|
|||
msgid "Environments|Learn more about stopping environments"
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|Logs from %{start} to %{end}."
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|New environment"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -14696,9 +14677,6 @@ msgstr ""
|
|||
msgid "Environments|Open live environment"
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|Pod name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|Re-deploy environment"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -14732,9 +14710,6 @@ msgstr ""
|
|||
msgid "Environments|There are no deployments for this environment yet. %{linkStart}Learn more about setting up deployments.%{linkEnd}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|There was an error fetching the logs. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|This action will %{docsStart}retry the latest deployment%{docsEnd} with the commit %{commitId}, for this environment. Are you sure you want to continue?"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -17690,9 +17665,15 @@ msgstr ""
|
|||
msgid "Global notification settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "GlobalSearch| %{search} %{description} %{scope}"
|
||||
msgstr ""
|
||||
|
||||
msgid "GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list."
|
||||
msgstr ""
|
||||
|
||||
msgid "GlobalSearch|Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "GlobalSearch|Issues I've created"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -17708,6 +17689,9 @@ msgstr ""
|
|||
msgid "GlobalSearch|Merge requests that I'm a reviewer"
|
||||
msgstr ""
|
||||
|
||||
msgid "GlobalSearch|Projects"
|
||||
msgstr ""
|
||||
|
||||
msgid "GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -17732,13 +17716,16 @@ msgstr ""
|
|||
msgid "GlobalSearch|What are you searching for?"
|
||||
msgstr ""
|
||||
|
||||
msgid "GlobalSearch|in all GitLab"
|
||||
msgid "GlobalSearch|all GitLab"
|
||||
msgstr ""
|
||||
|
||||
msgid "GlobalSearch|in group"
|
||||
msgid "GlobalSearch|group"
|
||||
msgstr ""
|
||||
|
||||
msgid "GlobalSearch|in project"
|
||||
msgid "GlobalSearch|in %{scope}"
|
||||
msgstr ""
|
||||
|
||||
msgid "GlobalSearch|project"
|
||||
msgstr ""
|
||||
|
||||
msgid "Globally-allowed IP ranges"
|
||||
|
|
@ -42433,9 +42420,6 @@ msgstr ""
|
|||
msgid "VersionCheck|Your GitLab Version"
|
||||
msgstr ""
|
||||
|
||||
msgid "View Documentation"
|
||||
msgstr ""
|
||||
|
||||
msgid "View Stage: %{title}"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ RSpec.describe Projects::ProjectDeletedEvent do
|
|||
where(:data, :valid) do
|
||||
[
|
||||
[{ project_id: 1, namespace_id: 2 }, true],
|
||||
[{ project_id: 1, namespace_id: 2, root_namespace_id: 3 }, true],
|
||||
[{ project_id: 1 }, false],
|
||||
[{ namespace_id: 1 }, false],
|
||||
[{ project_id: 'foo', namespace_id: 2 }, false],
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ RSpec.describe 'User uses header search field', :js do
|
|||
|
||||
it 'displays search options' do
|
||||
fill_in_search('test')
|
||||
|
||||
expect(page).to have_selector(scoped_search_link('test', search_code: true))
|
||||
expect(page).to have_selector(scoped_search_link('test', group_id: group.id, search_code: true))
|
||||
expect(page).to have_selector(scoped_search_link('test', project_id: project.id, group_id: group.id, search_code: true))
|
||||
|
|
@ -167,6 +168,7 @@ RSpec.describe 'User uses header search field', :js do
|
|||
|
||||
it 'displays search options' do
|
||||
fill_in_search('test')
|
||||
sleep 0.5
|
||||
expect(page).to have_selector(scoped_search_link('test', search_code: true, repository_ref: 'master'))
|
||||
expect(page).not_to have_selector(scoped_search_link('test', search_code: true, group_id: project.namespace_id, repository_ref: 'master'))
|
||||
expect(page).to have_selector(scoped_search_link('test', search_code: true, project_id: project.id, repository_ref: 'master'))
|
||||
|
|
@ -184,7 +186,7 @@ RSpec.describe 'User uses header search field', :js do
|
|||
fill_in_search('Feature')
|
||||
|
||||
within(dashboard_search_options_popup_menu) do
|
||||
expect(page).to have_text('"Feature" in all GitLab')
|
||||
expect(page).to have_text('Feature in all GitLab')
|
||||
expect(page).to have_no_text('Feature Flags')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { GlCollapse, GlPopover } from '@gitlab/ui';
|
||||
import { GlAccordionItem } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import Cookies from '~/lib/utils/cookies';
|
||||
import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
|
||||
import DesignNoteSignedOut from '~/design_management/components/design_notes/design_note_signed_out.vue';
|
||||
import DesignSidebar from '~/design_management/components/design_sidebar.vue';
|
||||
|
|
@ -27,8 +26,6 @@ const $route = {
|
|||
},
|
||||
};
|
||||
|
||||
const cookieKey = 'hide_design_resolved_comments_popover';
|
||||
|
||||
const mutate = jest.fn().mockResolvedValue();
|
||||
|
||||
describe('Design management design sidebar component', () => {
|
||||
|
|
@ -40,9 +37,7 @@ describe('Design management design sidebar component', () => {
|
|||
const findUnresolvedDiscussions = () => wrapper.findAll('[data-testid="unresolved-discussion"]');
|
||||
const findResolvedDiscussions = () => wrapper.findAll('[data-testid="resolved-discussion"]');
|
||||
const findParticipants = () => wrapper.find(Participants);
|
||||
const findCollapsible = () => wrapper.find(GlCollapse);
|
||||
const findToggleResolvedCommentsButton = () => wrapper.find('[data-testid="resolved-comments"]');
|
||||
const findPopover = () => wrapper.find(GlPopover);
|
||||
const findResolvedCommentsToggle = () => wrapper.find(GlAccordionItem);
|
||||
const findNewDiscussionDisclaimer = () =>
|
||||
wrapper.find('[data-testid="new-discussion-disclaimer"]');
|
||||
|
||||
|
|
@ -61,7 +56,6 @@ describe('Design management design sidebar component', () => {
|
|||
mutate,
|
||||
},
|
||||
},
|
||||
stubs: { GlPopover },
|
||||
provide: {
|
||||
registerPath: '/users/sign_up?redirect_to_referer=yes',
|
||||
signInPath: '/users/sign_in?redirect_to_referer=yes',
|
||||
|
|
@ -119,7 +113,6 @@ describe('Design management design sidebar component', () => {
|
|||
|
||||
describe('when has discussions', () => {
|
||||
beforeEach(() => {
|
||||
Cookies.set(cookieKey, true);
|
||||
createComponent();
|
||||
});
|
||||
|
||||
|
|
@ -131,26 +124,23 @@ describe('Design management design sidebar component', () => {
|
|||
expect(findResolvedDiscussions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('has resolved comments collapsible collapsed', () => {
|
||||
expect(findCollapsible().attributes('visible')).toBeUndefined();
|
||||
it('has resolved comments accordion item collapsed', () => {
|
||||
expect(findResolvedCommentsToggle().props('visible')).toBe(false);
|
||||
});
|
||||
|
||||
it('emits toggleResolveComments event on resolve comments button click', () => {
|
||||
findToggleResolvedCommentsButton().vm.$emit('click');
|
||||
it('emits toggleResolveComments event on resolve comments button click', async () => {
|
||||
findResolvedCommentsToggle().vm.$emit('input', true);
|
||||
await nextTick();
|
||||
expect(wrapper.emitted('toggleResolvedComments')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opens a collapsible when resolvedDiscussionsExpanded prop changes to true', async () => {
|
||||
expect(findCollapsible().attributes('visible')).toBeUndefined();
|
||||
it('opens the accordion item when resolvedDiscussionsExpanded prop changes to true', async () => {
|
||||
expect(findResolvedCommentsToggle().props('visible')).toBe(false);
|
||||
wrapper.setProps({
|
||||
resolvedDiscussionsExpanded: true,
|
||||
});
|
||||
await nextTick();
|
||||
expect(findCollapsible().attributes('visible')).toBe('true');
|
||||
});
|
||||
|
||||
it('does not popover about resolved comments', () => {
|
||||
expect(findPopover().exists()).toBe(false);
|
||||
expect(findResolvedCommentsToggle().props('visible')).toBe(true);
|
||||
});
|
||||
|
||||
it('sends a mutation to set an active discussion when clicking on a discussion', () => {
|
||||
|
|
@ -232,36 +222,6 @@ describe('Design management design sidebar component', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('when showing resolved discussions for the first time', () => {
|
||||
beforeEach(() => {
|
||||
Cookies.set(cookieKey, false);
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('renders a popover if we show resolved comments collapsible for the first time', () => {
|
||||
expect(findPopover().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('scrolls to resolved threads link', () => {
|
||||
expect(scrollIntoViewMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dismisses a popover on the outside click', async () => {
|
||||
wrapper.trigger('click');
|
||||
await nextTick();
|
||||
expect(findPopover().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it(`sets a ${cookieKey} cookie on clicking outside the popover`, () => {
|
||||
jest.spyOn(Cookies, 'set');
|
||||
wrapper.trigger('click');
|
||||
expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', {
|
||||
expires: 365 * 10,
|
||||
secure: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user is not logged in', () => {
|
||||
const findDesignNoteSignedOut = () => wrapper.findComponent(DesignNoteSignedOut);
|
||||
|
||||
|
|
@ -292,7 +252,6 @@ describe('Design management design sidebar component', () => {
|
|||
|
||||
describe('design has discussions', () => {
|
||||
beforeEach(() => {
|
||||
Cookies.set(cookieKey, true);
|
||||
createComponent();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -88,57 +88,26 @@ exports[`Design management design index page renders design index 1`] = `
|
|||
signinpath=""
|
||||
/>
|
||||
|
||||
<gl-button-stub
|
||||
buttontextclasses=""
|
||||
category="primary"
|
||||
class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
|
||||
data-testid="resolved-comments"
|
||||
icon="chevron-right"
|
||||
id="resolved-comments"
|
||||
size="medium"
|
||||
variant="link"
|
||||
<gl-accordion-stub
|
||||
class="gl-mb-5"
|
||||
headerlevel="3"
|
||||
>
|
||||
Resolved Comments (1)
|
||||
|
||||
</gl-button-stub>
|
||||
|
||||
<gl-popover-stub
|
||||
container="popovercontainer"
|
||||
cssclasses=""
|
||||
placement="top"
|
||||
show="true"
|
||||
target="resolved-comments"
|
||||
title="Resolved Comments"
|
||||
>
|
||||
<p>
|
||||
|
||||
Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below
|
||||
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
<gl-accordion-item-stub
|
||||
headerclass="gl-mb-5!"
|
||||
title="Resolved Comments (1)"
|
||||
>
|
||||
Learn more about resolving comments
|
||||
</a>
|
||||
</gl-popover-stub>
|
||||
|
||||
<gl-collapse-stub
|
||||
class="gl-mt-3"
|
||||
>
|
||||
<design-discussion-stub
|
||||
data-testid="resolved-discussion"
|
||||
designid="gid::/gitlab/Design/1"
|
||||
discussion="[object Object]"
|
||||
discussionwithopenform=""
|
||||
markdownpreviewpath="/project-path/preview_markdown?target_type=Issue"
|
||||
noteableid="gid::/gitlab/Design/1"
|
||||
registerpath=""
|
||||
signinpath=""
|
||||
/>
|
||||
</gl-collapse-stub>
|
||||
<design-discussion-stub
|
||||
data-testid="resolved-discussion"
|
||||
designid="gid::/gitlab/Design/1"
|
||||
discussion="[object Object]"
|
||||
discussionwithopenform=""
|
||||
markdownpreviewpath="/project-path/preview_markdown?target_type=Issue"
|
||||
noteableid="gid::/gitlab/Design/1"
|
||||
registerpath=""
|
||||
signinpath=""
|
||||
/>
|
||||
</gl-accordion-item-stub>
|
||||
</gl-accordion-stub>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { GlIcon } from '@gitlab/ui';
|
||||
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import DiffCodeQuality from '~/diffs/components/diff_code_quality.vue';
|
||||
import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/constants';
|
||||
import { multipleFindingsArr } from '../mock_data/diff_code_quality';
|
||||
|
||||
let wrapper;
|
||||
|
||||
const findIcon = () => wrapper.findComponent(GlIcon);
|
||||
|
||||
describe('DiffCodeQuality', () => {
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
const createWrapper = (codeQuality, mountFunction = mountExtended) => {
|
||||
return mountFunction(DiffCodeQuality, {
|
||||
propsData: {
|
||||
expandedLines: [],
|
||||
line: 1,
|
||||
codeQuality,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('hides details and throws hideCodeQualityFindings event on close click', async () => {
|
||||
wrapper = createWrapper(multipleFindingsArr);
|
||||
expect(wrapper.findByTestId('diff-codequality').exists()).toBe(true);
|
||||
|
||||
await wrapper.findByTestId('diff-codequality-close').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('hideCodeQualityFindings').length).toBe(1);
|
||||
expect(wrapper.emitted().hideCodeQualityFindings[0][0]).toBe(wrapper.props('line'));
|
||||
});
|
||||
|
||||
it('renders correct amount of list items for codequality array and their description', async () => {
|
||||
wrapper = createWrapper(multipleFindingsArr);
|
||||
const listItems = wrapper.findAll('li');
|
||||
|
||||
expect(wrapper.findAll('li').length).toBe(3);
|
||||
|
||||
listItems.wrappers.map((e, i) => {
|
||||
return expect(e.text()).toEqual(multipleFindingsArr[i].description);
|
||||
});
|
||||
});
|
||||
|
||||
it.each`
|
||||
severity
|
||||
${'info'}
|
||||
${'minor'}
|
||||
${'major'}
|
||||
${'critical'}
|
||||
${'blocker'}
|
||||
${'unknown'}
|
||||
`('shows icon for $severity degradation', ({ severity }) => {
|
||||
wrapper = createWrapper([{ severity }], shallowMountExtended);
|
||||
|
||||
expect(findIcon().exists()).toBe(true);
|
||||
|
||||
expect(findIcon().attributes()).toMatchObject({
|
||||
class: `codequality-severity-icon ${SEVERITY_CLASSES[severity]}`,
|
||||
name: SEVERITY_ICONS[severity],
|
||||
size: '12',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import Vue from 'vue';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import DiffView from '~/diffs/components/diff_view.vue';
|
||||
import DiffCodeQuality from '~/diffs/components/diff_code_quality.vue';
|
||||
import { diffCodeQuality } from '../mock_data/diff_code_quality';
|
||||
|
||||
describe('DiffView', () => {
|
||||
const DiffExpansionCell = { template: `<div/>` };
|
||||
|
|
@ -12,7 +14,7 @@ describe('DiffView', () => {
|
|||
const setSelectedCommentPosition = jest.fn();
|
||||
const getDiffRow = (wrapper) => wrapper.findComponent(DiffRow).vm;
|
||||
|
||||
const createWrapper = (props) => {
|
||||
const createWrapper = (props, provide = {}) => {
|
||||
Vue.use(Vuex);
|
||||
|
||||
const batchComments = {
|
||||
|
|
@ -46,9 +48,33 @@ describe('DiffView', () => {
|
|||
...props,
|
||||
};
|
||||
const stubs = { DiffExpansionCell, DiffRow, DiffCommentCell, DraftNote };
|
||||
return shallowMount(DiffView, { propsData, store, stubs });
|
||||
return shallowMount(DiffView, { propsData, store, stubs, provide });
|
||||
};
|
||||
|
||||
it('does not render a codeQuality diff view when there is no finding', () => {
|
||||
const wrapper = createWrapper();
|
||||
expect(wrapper.findComponent(DiffCodeQuality).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does render a codeQuality diff view with the correct props when there is a finding & refactorCodeQualityInlineFindings flag is true ', async () => {
|
||||
const wrapper = createWrapper(diffCodeQuality, {
|
||||
glFeatures: { refactorCodeQualityInlineFindings: true },
|
||||
});
|
||||
wrapper.findComponent(DiffRow).vm.$emit('toggleCodeQualityFindings', 2);
|
||||
await nextTick();
|
||||
expect(wrapper.findComponent(DiffCodeQuality).exists()).toBe(true);
|
||||
expect(wrapper.findComponent(DiffCodeQuality).props().codeQuality.length).not.toBe(0);
|
||||
});
|
||||
|
||||
it('does not render a codeQuality diff view when there is a finding & refactorCodeQualityInlineFindings flag is false ', async () => {
|
||||
const wrapper = createWrapper(diffCodeQuality, {
|
||||
glFeatures: { refactorCodeQualityInlineFindings: false },
|
||||
});
|
||||
wrapper.findComponent(DiffRow).vm.$emit('toggleCodeQualityFindings', 2);
|
||||
await nextTick();
|
||||
expect(wrapper.findComponent(DiffCodeQuality).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it.each`
|
||||
type | side | container | sides | total
|
||||
${'parallel'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {}, renderDiscussion: true }, right: { lineDraft: {}, renderDiscussion: true } }} | ${2}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
export const multipleFindingsArr = [
|
||||
{
|
||||
severity: 'minor',
|
||||
description: 'Unexpected Debugger Statement.',
|
||||
line: 2,
|
||||
},
|
||||
{
|
||||
severity: 'major',
|
||||
description:
|
||||
'Function `aVeryLongFunction` has 52 lines of code (exceeds 25 allowed). Consider refactoring.',
|
||||
line: 3,
|
||||
},
|
||||
{
|
||||
severity: 'minor',
|
||||
description: 'Arrow function has too many statements (52). Maximum allowed is 30.',
|
||||
line: 3,
|
||||
},
|
||||
];
|
||||
|
||||
export const multipleFindings = {
|
||||
filePath: 'index.js',
|
||||
codequality: multipleFindingsArr,
|
||||
};
|
||||
|
||||
export const singularFinding = {
|
||||
filePath: 'index.js',
|
||||
codequality: [multipleFindingsArr[0]],
|
||||
};
|
||||
|
||||
export const diffCodeQuality = {
|
||||
diffFile: { file_hash: '123' },
|
||||
diffLines: [
|
||||
{
|
||||
left: {
|
||||
type: 'old',
|
||||
old_line: 1,
|
||||
new_line: null,
|
||||
codequality: [],
|
||||
lineDraft: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
left: {
|
||||
type: null,
|
||||
old_line: 2,
|
||||
new_line: 1,
|
||||
codequality: [],
|
||||
lineDraft: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
left: {
|
||||
type: 'new',
|
||||
old_line: null,
|
||||
new_line: 2,
|
||||
|
||||
codequality: [multipleFindingsArr[0]],
|
||||
lineDraft: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -1,65 +1,50 @@
|
|||
import Vue, { nextTick } from 'vue';
|
||||
|
||||
import groupFolderComponent from '~/groups/components/group_folder.vue';
|
||||
import groupItemComponent from '~/groups/components/group_item.vue';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import Vue from 'vue';
|
||||
import GroupFolder from '~/groups/components/group_folder.vue';
|
||||
import GroupItem from '~/groups/components/group_item.vue';
|
||||
import { MAX_CHILDREN_COUNT } from '~/groups/constants';
|
||||
import { mockGroups, mockParentGroupItem } from '../mock_data';
|
||||
|
||||
const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => {
|
||||
const Component = Vue.extend(groupFolderComponent);
|
||||
describe('GroupFolder component', () => {
|
||||
let wrapper;
|
||||
|
||||
return new Component({
|
||||
propsData: {
|
||||
groups,
|
||||
parentGroup,
|
||||
},
|
||||
});
|
||||
};
|
||||
Vue.component('GroupItem', GroupItem);
|
||||
|
||||
describe('GroupFolderComponent', () => {
|
||||
let vm;
|
||||
const findLink = () => wrapper.find('a');
|
||||
|
||||
beforeEach(async () => {
|
||||
Vue.component('GroupItem', groupItemComponent);
|
||||
|
||||
vm = createComponent();
|
||||
vm.$mount();
|
||||
|
||||
await nextTick();
|
||||
});
|
||||
const createComponent = ({ groups = mockGroups, parentGroup = mockParentGroupItem } = {}) =>
|
||||
shallowMount(GroupFolder, {
|
||||
propsData: {
|
||||
groups,
|
||||
parentGroup,
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('computed', () => {
|
||||
describe('hasMoreChildren', () => {
|
||||
it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => {
|
||||
expect(vm.hasMoreChildren).toBeFalsy();
|
||||
});
|
||||
});
|
||||
it('does not render more children stats link when children count of group is under limit', () => {
|
||||
wrapper = createComponent();
|
||||
|
||||
describe('moreChildrenStats', () => {
|
||||
it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => {
|
||||
expect(vm.moreChildrenStats).toBe('3 more items');
|
||||
});
|
||||
});
|
||||
expect(findLink().exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
it('should render component template correctly', () => {
|
||||
expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy();
|
||||
expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7);
|
||||
it('renders text of count of excess children when children count of group is over limit', () => {
|
||||
const childrenCount = MAX_CHILDREN_COUNT + 1;
|
||||
wrapper = createComponent({
|
||||
parentGroup: {
|
||||
...mockParentGroupItem,
|
||||
childrenCount,
|
||||
},
|
||||
});
|
||||
|
||||
it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => {
|
||||
const parentGroup = { ...mockParentGroupItem };
|
||||
parentGroup.childrenCount = 21;
|
||||
expect(findLink().text()).toBe(`${childrenCount} more items`);
|
||||
});
|
||||
|
||||
const newVm = createComponent(mockGroups, parentGroup);
|
||||
newVm.$mount();
|
||||
it('renders group items', () => {
|
||||
wrapper = createComponent();
|
||||
|
||||
expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined();
|
||||
newVm.$destroy();
|
||||
});
|
||||
expect(wrapper.findAllComponents(GroupItem)).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,26 +5,6 @@ export const ITEM_TYPE = {
|
|||
GROUP: 'group',
|
||||
};
|
||||
|
||||
export const GROUP_VISIBILITY_TYPE = {
|
||||
public: 'Public - The group and any public projects can be viewed without any authentication.',
|
||||
internal:
|
||||
'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
|
||||
private: 'Private - The group and its projects can only be viewed by members.',
|
||||
};
|
||||
|
||||
export const PROJECT_VISIBILITY_TYPE = {
|
||||
public: 'Public - The project can be accessed without any authentication.',
|
||||
internal: 'Internal - The project can be accessed by any logged in user except external users.',
|
||||
private:
|
||||
'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
|
||||
};
|
||||
|
||||
export const VISIBILITY_TYPE_ICON = {
|
||||
public: 'earth',
|
||||
internal: 'shield',
|
||||
private: 'lock',
|
||||
};
|
||||
|
||||
export const mockParentGroupItem = {
|
||||
id: 55,
|
||||
name: 'hardware',
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
import { GlSearchBoxByType } from '@gitlab/ui';
|
||||
import { GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import { s__, sprintf } from '~/locale';
|
||||
import HeaderSearchApp from '~/header_search/components/app.vue';
|
||||
import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue';
|
||||
import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue';
|
||||
import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
|
||||
import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION } from '~/header_search/constants';
|
||||
import {
|
||||
SEARCH_INPUT_DESCRIPTION,
|
||||
SEARCH_RESULTS_DESCRIPTION,
|
||||
SEARCH_BOX_INDEX,
|
||||
ICON_PROJECT,
|
||||
ICON_GROUP,
|
||||
ICON_SUBGROUP,
|
||||
SCOPE_TOKEN_MAX_LENGTH,
|
||||
} from '~/header_search/constants';
|
||||
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
|
||||
import { ENTER_KEY } from '~/lib/utils/keys';
|
||||
import { visitUrl } from '~/lib/utils/url_utility';
|
||||
import { truncate } from '~/lib/utils/text_utility';
|
||||
import {
|
||||
MOCK_SEARCH,
|
||||
MOCK_SEARCH_QUERY,
|
||||
MOCK_USERNAME,
|
||||
MOCK_DEFAULT_SEARCH_OPTIONS,
|
||||
MOCK_SCOPED_SEARCH_OPTIONS,
|
||||
MOCK_SORTED_AUTOCOMPLETE_OPTIONS,
|
||||
MOCK_SEARCH_CONTEXT_FULL,
|
||||
} from '../mock_data';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
|
@ -52,11 +62,26 @@ describe('HeaderSearchApp', () => {
|
|||
});
|
||||
};
|
||||
|
||||
const formatScopeName = (scopeName) => {
|
||||
if (!scopeName) {
|
||||
return false;
|
||||
}
|
||||
const searchResultsScope = s__('GlobalSearch|in %{scope}');
|
||||
return truncate(
|
||||
sprintf(searchResultsScope, {
|
||||
scope: scopeName,
|
||||
}),
|
||||
SCOPE_TOKEN_MAX_LENGTH,
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
const findHeaderSearchForm = () => wrapper.findByTestId('header-search-form');
|
||||
const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
|
||||
const findScopeToken = () => wrapper.findComponent(GlToken);
|
||||
const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu');
|
||||
const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems);
|
||||
const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems);
|
||||
|
|
@ -106,53 +131,38 @@ describe('HeaderSearchApp', () => {
|
|||
});
|
||||
|
||||
describe.each`
|
||||
search | showDefault | showScoped | showAutocomplete | showDropdownNavigation
|
||||
${null} | ${true} | ${false} | ${false} | ${true}
|
||||
${''} | ${true} | ${false} | ${false} | ${true}
|
||||
${'1'} | ${false} | ${false} | ${false} | ${false}
|
||||
${')'} | ${false} | ${false} | ${false} | ${false}
|
||||
${'t'} | ${false} | ${false} | ${true} | ${true}
|
||||
${'te'} | ${false} | ${true} | ${true} | ${true}
|
||||
${'tes'} | ${false} | ${true} | ${true} | ${true}
|
||||
${MOCK_SEARCH} | ${false} | ${true} | ${true} | ${true}
|
||||
`(
|
||||
'Header Search Dropdown Items',
|
||||
({ search, showDefault, showScoped, showAutocomplete, showDropdownNavigation }) => {
|
||||
describe(`when search is ${search}`, () => {
|
||||
beforeEach(() => {
|
||||
window.gon.current_username = MOCK_USERNAME;
|
||||
createComponent(
|
||||
{ search },
|
||||
{
|
||||
autocompleteGroupedSearchOptions: () =>
|
||||
search.match(/^[A-Za-z]+$/g) ? MOCK_SORTED_AUTOCOMPLETE_OPTIONS : [],
|
||||
},
|
||||
);
|
||||
findHeaderSearchInput().vm.$emit('click');
|
||||
});
|
||||
|
||||
it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => {
|
||||
expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault);
|
||||
});
|
||||
|
||||
it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => {
|
||||
expect(findHeaderSearchScopedItems().exists()).toBe(showScoped);
|
||||
});
|
||||
|
||||
it(`should${
|
||||
showAutocomplete ? '' : ' not'
|
||||
} render the Autocomplete Dropdown Items`, () => {
|
||||
expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete);
|
||||
});
|
||||
|
||||
it(`should${
|
||||
showDropdownNavigation ? '' : ' not'
|
||||
} render the Dropdown Navigation Component`, () => {
|
||||
expect(findDropdownKeyboardNavigation().exists()).toBe(showDropdownNavigation);
|
||||
});
|
||||
search | showDefault | showScoped | showAutocomplete
|
||||
${null} | ${true} | ${false} | ${false}
|
||||
${''} | ${true} | ${false} | ${false}
|
||||
${'t'} | ${false} | ${false} | ${true}
|
||||
${'te'} | ${false} | ${false} | ${true}
|
||||
${'tes'} | ${false} | ${true} | ${true}
|
||||
${MOCK_SEARCH} | ${false} | ${true} | ${true}
|
||||
`('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => {
|
||||
describe(`when search is ${search}`, () => {
|
||||
beforeEach(() => {
|
||||
window.gon.current_username = MOCK_USERNAME;
|
||||
createComponent({ search }, {});
|
||||
findHeaderSearchInput().vm.$emit('click');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => {
|
||||
expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault);
|
||||
});
|
||||
|
||||
it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => {
|
||||
expect(findHeaderSearchScopedItems().exists()).toBe(showScoped);
|
||||
});
|
||||
|
||||
it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => {
|
||||
expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete);
|
||||
});
|
||||
|
||||
it(`should render the Dropdown Navigation Component`, () => {
|
||||
expect(findDropdownKeyboardNavigation().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
username | showDropdown | expectedDesc
|
||||
|
|
@ -185,12 +195,18 @@ describe('HeaderSearchApp', () => {
|
|||
`(
|
||||
'Search Results Description',
|
||||
({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => {
|
||||
describe(`search is ${search}, loading is ${loading}, and showSearchDropdown is ${
|
||||
Boolean(username) && showDropdown
|
||||
}`, () => {
|
||||
describe(`search is "${search}", loading is ${loading}, and showSearchDropdown is ${showDropdown}`, () => {
|
||||
beforeEach(() => {
|
||||
window.gon.current_username = username;
|
||||
createComponent({ search, loading }, { searchOptions: () => searchOptions });
|
||||
createComponent(
|
||||
{
|
||||
search,
|
||||
loading,
|
||||
},
|
||||
{
|
||||
searchOptions: () => searchOptions,
|
||||
},
|
||||
);
|
||||
findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : '');
|
||||
});
|
||||
|
||||
|
|
@ -200,6 +216,121 @@ describe('HeaderSearchApp', () => {
|
|||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe('input box', () => {
|
||||
describe.each`
|
||||
search | searchOptions | hasToken
|
||||
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true}
|
||||
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[1]]} | ${true}
|
||||
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true}
|
||||
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true}
|
||||
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${true}
|
||||
${'te'} | ${[MOCK_SCOPED_SEARCH_OPTIONS[5]]} | ${false}
|
||||
${'x'} | ${[]} | ${false}
|
||||
`('token', ({ search, searchOptions, hasToken }) => {
|
||||
beforeEach(() => {
|
||||
window.gon.current_username = MOCK_USERNAME;
|
||||
createComponent(
|
||||
{ search },
|
||||
{
|
||||
searchOptions: () => searchOptions,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${
|
||||
searchOptions[0]?.html_id
|
||||
}"`, () => {
|
||||
expect(findScopeToken().exists()).toBe(hasToken);
|
||||
});
|
||||
|
||||
it(`text ${hasToken ? 'is correctly' : 'is NOT'} rendered when text is "${
|
||||
searchOptions[0]?.scope || searchOptions[0]?.description
|
||||
}"`, () => {
|
||||
expect(findScopeToken().exists() && findScopeToken().text()).toBe(
|
||||
formatScopeName(searchOptions[0]?.scope || searchOptions[0]?.description),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('form wrapper', () => {
|
||||
describe.each`
|
||||
searchContext | search | searchOptions
|
||||
${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]}
|
||||
${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]}
|
||||
${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS}
|
||||
${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS}
|
||||
${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS}
|
||||
${null} | ${null} | ${[]}
|
||||
`('', ({ searchContext, search, searchOptions }) => {
|
||||
beforeEach(() => {
|
||||
window.gon.current_username = MOCK_USERNAME;
|
||||
|
||||
createComponent({ search, searchContext }, { searchOptions: () => searchOptions });
|
||||
|
||||
findHeaderSearchInput().vm.$emit('click');
|
||||
});
|
||||
|
||||
const hasIcon = Boolean(searchContext?.group);
|
||||
const isSearching = Boolean(search);
|
||||
const isActive = Boolean(searchOptions.length > 0);
|
||||
|
||||
it(`${hasIcon ? 'with' : 'without'} search context classes contain "${
|
||||
hasIcon ? 'has-icon' : 'has-no-icon'
|
||||
}"`, () => {
|
||||
const iconClassRegex = hasIcon ? 'has-icon' : 'has-no-icon';
|
||||
expect(findHeaderSearchForm().classes()).toContain(iconClassRegex);
|
||||
});
|
||||
|
||||
it(`${isSearching ? 'with' : 'without'} search string classes contain "${
|
||||
isSearching ? 'is-searching' : 'is-not-searching'
|
||||
}"`, () => {
|
||||
const iconClassRegex = isSearching ? 'is-searching' : 'is-not-searching';
|
||||
expect(findHeaderSearchForm().classes()).toContain(iconClassRegex);
|
||||
});
|
||||
|
||||
it(`${isActive ? 'with' : 'without'} search results classes contain "${
|
||||
isActive ? 'is-active' : 'is-not-active'
|
||||
}"`, () => {
|
||||
const iconClassRegex = isActive ? 'is-active' : 'is-not-active';
|
||||
expect(findHeaderSearchForm().classes()).toContain(iconClassRegex);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
search | searchOptions | hasIcon | iconName
|
||||
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true} | ${ICON_PROJECT}
|
||||
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true} | ${ICON_GROUP}
|
||||
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true} | ${ICON_SUBGROUP}
|
||||
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${false} | ${false}
|
||||
`('token', ({ search, searchOptions, hasIcon, iconName }) => {
|
||||
beforeEach(() => {
|
||||
window.gon.current_username = MOCK_USERNAME;
|
||||
createComponent(
|
||||
{ search },
|
||||
{
|
||||
searchOptions: () => searchOptions,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it(`icon for data set type "${searchOptions[0]?.html_id}" ${
|
||||
hasIcon ? 'is' : 'is NOT'
|
||||
} rendered`, () => {
|
||||
expect(findScopeToken().findComponent(GlIcon).exists()).toBe(hasIcon);
|
||||
});
|
||||
|
||||
it(`render ${iconName ? `"${iconName}"` : 'NO'} icon for data set type "${
|
||||
searchOptions[0]?.html_id
|
||||
}"`, () => {
|
||||
expect(
|
||||
findScopeToken().findComponent(GlIcon).exists() &&
|
||||
findScopeToken().findComponent(GlIcon).attributes('name'),
|
||||
).toBe(iconName);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('events', () => {
|
||||
|
|
@ -285,18 +416,20 @@ describe('HeaderSearchApp', () => {
|
|||
});
|
||||
|
||||
describe('computed', () => {
|
||||
describe('currentFocusedOption', () => {
|
||||
const MOCK_INDEX = 1;
|
||||
|
||||
describe.each`
|
||||
MOCK_INDEX | search
|
||||
${1} | ${null}
|
||||
${SEARCH_BOX_INDEX} | ${'test'}
|
||||
${2} | ${'test1'}
|
||||
`('currentFocusedOption', ({ MOCK_INDEX, search }) => {
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
createComponent({ search });
|
||||
window.gon.current_username = MOCK_USERNAME;
|
||||
findHeaderSearchInput().vm.$emit('click');
|
||||
});
|
||||
|
||||
it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, async () => {
|
||||
it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, () => {
|
||||
findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX);
|
||||
await nextTick();
|
||||
expect(wrapper.vm.currentFocusedOption).toBe(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX]);
|
||||
});
|
||||
});
|
||||
|
|
@ -308,15 +441,25 @@ describe('HeaderSearchApp', () => {
|
|||
createComponent();
|
||||
});
|
||||
|
||||
it('onKey-enter submits a search', async () => {
|
||||
it('onKey-enter submits a search', () => {
|
||||
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with less than min characters and no dropdown results', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ search: 'x' });
|
||||
});
|
||||
|
||||
it('onKey-enter will NOT submit a search', () => {
|
||||
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
|
||||
|
||||
expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with currentFocusedOption', () => {
|
||||
const MOCK_INDEX = 1;
|
||||
|
||||
|
|
@ -326,9 +469,9 @@ describe('HeaderSearchApp', () => {
|
|||
findHeaderSearchInput().vm.$emit('click');
|
||||
});
|
||||
|
||||
it('onKey-enter clicks the selected dropdown item rather than submitting a search', async () => {
|
||||
it('onKey-enter clicks the selected dropdown item rather than submitting a search', () => {
|
||||
findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX);
|
||||
await nextTick();
|
||||
|
||||
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
|
||||
expect(visitUrl).toHaveBeenCalledWith(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX].url);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
|
||||
import { GlDropdownItem, GlToken, GlIcon } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import { trimText } from 'helpers/text_helper';
|
||||
import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
|
||||
import { truncate } from '~/lib/utils/text_utility';
|
||||
import { MSG_IN_ALL_GITLAB, SCOPE_TOKEN_MAX_LENGTH } from '~/header_search/constants';
|
||||
import {
|
||||
MOCK_SEARCH,
|
||||
MOCK_SCOPED_SEARCH_OPTIONS,
|
||||
|
|
@ -41,9 +43,12 @@ describe('HeaderSearchScopedItems', () => {
|
|||
});
|
||||
|
||||
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
|
||||
const findGlDropdownDivider = () => wrapper.findComponent(GlDropdownDivider);
|
||||
const findFirstDropdownItem = () => findDropdownItems().at(0);
|
||||
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text()));
|
||||
const findScopeTokens = () => wrapper.findAllComponents(GlToken);
|
||||
const findScopeTokensText = () => findScopeTokens().wrappers.map((w) => trimText(w.text()));
|
||||
const findScopeTokensIcons = () =>
|
||||
findScopeTokens().wrappers.map((w) => w.findAllComponents(GlIcon));
|
||||
const findDropdownItemAriaLabels = () =>
|
||||
findDropdownItems().wrappers.map((w) => trimText(w.attributes('aria-label')));
|
||||
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
|
||||
|
|
@ -59,15 +64,31 @@ describe('HeaderSearchScopedItems', () => {
|
|||
});
|
||||
|
||||
it('renders titles correctly', () => {
|
||||
findDropdownItemTitles().forEach((title) => expect(title).toContain(MOCK_SEARCH));
|
||||
});
|
||||
|
||||
it('renders scope names correctly', () => {
|
||||
const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) =>
|
||||
trimText(`"${MOCK_SEARCH}" ${o.description} ${o.scope || ''}`),
|
||||
truncate(trimText(`in ${o.description || o.scope}`), SCOPE_TOKEN_MAX_LENGTH),
|
||||
);
|
||||
expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
|
||||
|
||||
expect(findScopeTokensText()).toStrictEqual(expectedTitles);
|
||||
});
|
||||
|
||||
it('renders scope icons correctly', () => {
|
||||
findScopeTokensIcons().forEach((icon, i) => {
|
||||
const w = icon.wrappers[0];
|
||||
expect(w?.attributes('name')).toBe(MOCK_SCOPED_SEARCH_OPTIONS[i].icon);
|
||||
});
|
||||
});
|
||||
|
||||
it(`renders scope ${MSG_IN_ALL_GITLAB} correctly`, () => {
|
||||
expect(findScopeTokens().at(-1).findComponent(GlIcon).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders aria-labels correctly', () => {
|
||||
const expectedLabels = MOCK_SCOPED_SEARCH_OPTIONS.map((o) =>
|
||||
trimText(`${MOCK_SEARCH} ${o.description} ${o.scope || ''}`),
|
||||
trimText(`${MOCK_SEARCH} ${o.description || o.icon} ${o.scope || ''}`),
|
||||
);
|
||||
expect(findDropdownItemAriaLabels()).toStrictEqual(expectedLabels);
|
||||
});
|
||||
|
|
@ -98,21 +119,5 @@ describe('HeaderSearchScopedItems', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
autosuggestResults | showDivider
|
||||
${[]} | ${false}
|
||||
${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${true}
|
||||
`('scoped search items', ({ autosuggestResults, showDivider }) => {
|
||||
describe(`when when we have ${autosuggestResults.length} auto-sugest results`, () => {
|
||||
beforeEach(() => {
|
||||
createComponent({}, { autocompleteGroupedSearchOptions: () => autosuggestResults }, {});
|
||||
});
|
||||
|
||||
it(`divider should${showDivider ? '' : ' not'} be shown`, () => {
|
||||
expect(findGlDropdownDivider().exists()).toBe(showDivider);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ import {
|
|||
MSG_MR_ASSIGNED_TO_ME,
|
||||
MSG_MR_IM_REVIEWER,
|
||||
MSG_MR_IVE_CREATED,
|
||||
MSG_IN_PROJECT,
|
||||
MSG_IN_GROUP,
|
||||
MSG_IN_ALL_GITLAB,
|
||||
PROJECTS_CATEGORY,
|
||||
ICON_PROJECT,
|
||||
GROUPS_CATEGORY,
|
||||
ICON_GROUP,
|
||||
ICON_SUBGROUP,
|
||||
} from '~/header_search/constants';
|
||||
|
||||
export const MOCK_USERNAME = 'anyone';
|
||||
|
|
@ -27,12 +30,24 @@ export const MOCK_PROJECT = {
|
|||
path: '/mock-project',
|
||||
};
|
||||
|
||||
export const MOCK_PROJECT_LONG = {
|
||||
id: 124,
|
||||
name: 'Mock Project Name That Is Ridiculously Long And It Goes Forever',
|
||||
path: '/mock-project-name-that-is-ridiculously-long-and-it-goes-forever',
|
||||
};
|
||||
|
||||
export const MOCK_GROUP = {
|
||||
id: 321,
|
||||
name: 'MockGroup',
|
||||
path: '/mock-group',
|
||||
};
|
||||
|
||||
export const MOCK_SUBGROUP = {
|
||||
id: 322,
|
||||
name: 'MockSubGroup',
|
||||
path: `${MOCK_GROUP}/mock-subgroup`,
|
||||
};
|
||||
|
||||
export const MOCK_SEARCH_QUERY = 'http://gitlab.com/search?search=test';
|
||||
|
||||
export const MOCK_SEARCH = 'test';
|
||||
|
|
@ -44,6 +59,20 @@ export const MOCK_SEARCH_CONTEXT = {
|
|||
group_metadata: {},
|
||||
};
|
||||
|
||||
export const MOCK_SEARCH_CONTEXT_FULL = {
|
||||
group: {
|
||||
id: 31,
|
||||
name: 'testGroup',
|
||||
full_name: 'testGroup',
|
||||
},
|
||||
group_metadata: {
|
||||
group_path: 'testGroup',
|
||||
name: 'testGroup',
|
||||
issues_path: '/groups/testGroup/-/issues',
|
||||
mr_path: '/groups/testGroup/-/merge_requests',
|
||||
},
|
||||
};
|
||||
|
||||
export const MOCK_DEFAULT_SEARCH_OPTIONS = [
|
||||
{
|
||||
html_id: 'default-issues-assigned',
|
||||
|
|
@ -76,13 +105,51 @@ export const MOCK_SCOPED_SEARCH_OPTIONS = [
|
|||
{
|
||||
html_id: 'scoped-in-project',
|
||||
scope: MOCK_PROJECT.name,
|
||||
description: MSG_IN_PROJECT,
|
||||
scopeCategory: PROJECTS_CATEGORY,
|
||||
icon: ICON_PROJECT,
|
||||
url: MOCK_PROJECT.path,
|
||||
},
|
||||
{
|
||||
html_id: 'scoped-in-project-long',
|
||||
scope: MOCK_PROJECT_LONG.name,
|
||||
scopeCategory: PROJECTS_CATEGORY,
|
||||
icon: ICON_PROJECT,
|
||||
url: MOCK_PROJECT_LONG.path,
|
||||
},
|
||||
{
|
||||
html_id: 'scoped-in-group',
|
||||
scope: MOCK_GROUP.name,
|
||||
scopeCategory: GROUPS_CATEGORY,
|
||||
icon: ICON_GROUP,
|
||||
url: MOCK_GROUP.path,
|
||||
},
|
||||
{
|
||||
html_id: 'scoped-in-subgroup',
|
||||
scope: MOCK_SUBGROUP.name,
|
||||
scopeCategory: GROUPS_CATEGORY,
|
||||
icon: ICON_SUBGROUP,
|
||||
url: MOCK_SUBGROUP.path,
|
||||
},
|
||||
{
|
||||
html_id: 'scoped-in-all',
|
||||
description: MSG_IN_ALL_GITLAB,
|
||||
url: MOCK_ALL_PATH,
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [
|
||||
{
|
||||
html_id: 'scoped-in-project',
|
||||
scope: MOCK_PROJECT.name,
|
||||
scopeCategory: PROJECTS_CATEGORY,
|
||||
icon: ICON_PROJECT,
|
||||
url: MOCK_PROJECT.path,
|
||||
},
|
||||
{
|
||||
html_id: 'scoped-in-group',
|
||||
scope: MOCK_GROUP.name,
|
||||
description: MSG_IN_GROUP,
|
||||
scopeCategory: GROUPS_CATEGORY,
|
||||
icon: ICON_GROUP,
|
||||
url: MOCK_GROUP.path,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
MOCK_SEARCH_CONTEXT,
|
||||
MOCK_DEFAULT_SEARCH_OPTIONS,
|
||||
MOCK_SCOPED_SEARCH_OPTIONS,
|
||||
MOCK_SCOPED_SEARCH_OPTIONS_DEF,
|
||||
MOCK_PROJECT,
|
||||
MOCK_GROUP,
|
||||
MOCK_ALL_PATH,
|
||||
|
|
@ -284,7 +285,7 @@ describe('Header Search Store Getters', () => {
|
|||
|
||||
it('returns the correct array', () => {
|
||||
expect(getters.scopedSearchOptions(state, mockGetters)).toStrictEqual(
|
||||
MOCK_SCOPED_SEARCH_OPTIONS,
|
||||
MOCK_SCOPED_SEARCH_OPTIONS_DEF,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -308,6 +309,11 @@ describe('Header Search Store Getters', () => {
|
|||
${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${[]} | ${MOCK_SCOPED_SEARCH_OPTIONS}
|
||||
${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
|
||||
${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)}
|
||||
${1} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]}
|
||||
${'('} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]}
|
||||
${'t'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
|
||||
${'te'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
|
||||
${'tes'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)}
|
||||
`(
|
||||
'searchOptions',
|
||||
({
|
||||
|
|
|
|||
|
|
@ -1,29 +1,21 @@
|
|||
import Vue from 'vue';
|
||||
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
|
||||
import emptyState from '~/ide/components/commit_sidebar/empty_state.vue';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import EmptyState from '~/ide/components/commit_sidebar/empty_state.vue';
|
||||
import { createStore } from '~/ide/stores';
|
||||
|
||||
describe('IDE commit panel empty state', () => {
|
||||
let vm;
|
||||
let store;
|
||||
describe('IDE commit panel EmptyState component', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
|
||||
const Component = Vue.extend(emptyState);
|
||||
|
||||
Vue.set(store.state, 'noChangesStateSvgPath', 'no-changes');
|
||||
|
||||
vm = createComponentWithStore(Component, store);
|
||||
|
||||
vm.$mount();
|
||||
const store = createStore();
|
||||
store.state.noChangesStateSvgPath = 'no-changes';
|
||||
wrapper = shallowMount(EmptyState, { store });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders no changes text when last commit message is empty', () => {
|
||||
expect(vm.$el.textContent).toContain('No changes');
|
||||
expect(wrapper.find('h4').text()).toBe('No changes');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,51 +1,47 @@
|
|||
import Vue, { nextTick } from 'vue';
|
||||
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
|
||||
import commitSidebarList from '~/ide/components/commit_sidebar/list.vue';
|
||||
import { createStore } from '~/ide/stores';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import CommitSidebarList from '~/ide/components/commit_sidebar/list.vue';
|
||||
import ListItem from '~/ide/components/commit_sidebar/list_item.vue';
|
||||
import { file } from '../../helpers';
|
||||
|
||||
describe('Multi-file editor commit sidebar list', () => {
|
||||
let store;
|
||||
let vm;
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
|
||||
const Component = Vue.extend(commitSidebarList);
|
||||
|
||||
vm = createComponentWithStore(Component, store, {
|
||||
title: 'Staged',
|
||||
fileList: [],
|
||||
action: 'stageAllChanges',
|
||||
actionBtnText: 'stage all',
|
||||
actionBtnIcon: 'history',
|
||||
activeFileKey: 'staged-testing',
|
||||
keyPrefix: 'staged',
|
||||
const mountComponent = ({ fileList }) =>
|
||||
shallowMount(CommitSidebarList, {
|
||||
propsData: {
|
||||
title: 'Staged',
|
||||
fileList,
|
||||
action: 'stageAllChanges',
|
||||
actionBtnText: 'stage all',
|
||||
actionBtnIcon: 'history',
|
||||
activeFileKey: 'staged-testing',
|
||||
keyPrefix: 'staged',
|
||||
},
|
||||
});
|
||||
|
||||
vm.$mount();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('with a list of files', () => {
|
||||
beforeEach(async () => {
|
||||
const f = file('file name');
|
||||
f.changed = true;
|
||||
vm.fileList.push(f);
|
||||
await nextTick();
|
||||
wrapper = mountComponent({ fileList: [f] });
|
||||
});
|
||||
|
||||
it('renders list', () => {
|
||||
expect(vm.$el.querySelectorAll('.multi-file-commit-list > li').length).toBe(1);
|
||||
expect(wrapper.findAllComponents(ListItem)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty files array', () => {
|
||||
it('renders no changes text when empty', () => {
|
||||
expect(vm.$el.textContent).toContain('No changes');
|
||||
describe('with empty files array', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = mountComponent({ fileList: [] });
|
||||
});
|
||||
|
||||
it('renders no changes text ', () => {
|
||||
expect(wrapper.text()).toContain('No changes');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,32 +1,22 @@
|
|||
import Vue, { nextTick } from 'vue';
|
||||
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
|
||||
import successMessage from '~/ide/components/commit_sidebar/success_message.vue';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import SuccessMessage from '~/ide/components/commit_sidebar/success_message.vue';
|
||||
import { createStore } from '~/ide/stores';
|
||||
|
||||
describe('IDE commit panel successful commit state', () => {
|
||||
let vm;
|
||||
let store;
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
|
||||
const Component = Vue.extend(successMessage);
|
||||
|
||||
vm = createComponentWithStore(Component, store, {
|
||||
committedStateSvgPath: 'committed-state',
|
||||
});
|
||||
|
||||
vm.$mount();
|
||||
const store = createStore();
|
||||
store.state.committedStateSvgPath = 'committed-state';
|
||||
store.state.lastCommitMsg = 'testing commit message';
|
||||
wrapper = shallowMount(SuccessMessage, { store });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders last commit message when it exists', async () => {
|
||||
vm.$store.state.lastCommitMsg = 'testing commit message';
|
||||
|
||||
await nextTick();
|
||||
expect(vm.$el.textContent).toContain('testing commit message');
|
||||
it('renders last commit message when it exists', () => {
|
||||
expect(wrapper.text()).toContain('testing commit message');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,82 +1,72 @@
|
|||
import Vue, { nextTick } from 'vue';
|
||||
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
|
||||
import { GlSkeletonLoader } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import Vue from 'vue';
|
||||
import IdeTreeList from '~/ide/components/ide_tree_list.vue';
|
||||
import { createStore } from '~/ide/stores';
|
||||
import FileTree from '~/vue_shared/components/file_tree.vue';
|
||||
import { file } from '../helpers';
|
||||
import { projectData } from '../mock_data';
|
||||
|
||||
describe('IDE tree list', () => {
|
||||
const Component = Vue.extend(IdeTreeList);
|
||||
const normalBranchTree = [file('fileName')];
|
||||
const emptyBranchTree = [];
|
||||
let vm;
|
||||
let store;
|
||||
describe('IdeTreeList component', () => {
|
||||
let wrapper;
|
||||
|
||||
const bootstrapWithTree = (tree = normalBranchTree) => {
|
||||
const mountComponent = ({ tree, loading = false } = {}) => {
|
||||
const store = createStore();
|
||||
store.state.currentProjectId = 'abcproject';
|
||||
store.state.currentBranchId = 'main';
|
||||
store.state.projects.abcproject = { ...projectData };
|
||||
Vue.set(store.state.trees, 'abcproject/main', {
|
||||
tree,
|
||||
loading: false,
|
||||
});
|
||||
Vue.set(store.state.trees, 'abcproject/main', { tree, loading });
|
||||
|
||||
vm = createComponentWithStore(Component, store, {
|
||||
viewerType: 'edit',
|
||||
wrapper = shallowMount(IdeTreeList, {
|
||||
propsData: {
|
||||
viewerType: 'edit',
|
||||
},
|
||||
store,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('normal branch', () => {
|
||||
beforeEach(() => {
|
||||
bootstrapWithTree();
|
||||
|
||||
jest.spyOn(vm, '$emit').mockImplementation(() => {});
|
||||
|
||||
vm.$mount();
|
||||
});
|
||||
const tree = [file('fileName')];
|
||||
|
||||
it('emits tree-ready event', () => {
|
||||
expect(vm.$emit).toHaveBeenCalledTimes(1);
|
||||
expect(vm.$emit).toHaveBeenCalledWith('tree-ready');
|
||||
mountComponent({ tree });
|
||||
|
||||
expect(wrapper.emitted('tree-ready')).toEqual([[]]);
|
||||
});
|
||||
|
||||
it('renders loading indicator', async () => {
|
||||
store.state.trees['abcproject/main'].loading = true;
|
||||
it('renders loading indicator', () => {
|
||||
mountComponent({ tree, loading: true });
|
||||
|
||||
await nextTick();
|
||||
expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
|
||||
expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
|
||||
expect(wrapper.findAllComponents(GlSkeletonLoader)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders list of files', () => {
|
||||
expect(vm.$el.textContent).toContain('fileName');
|
||||
mountComponent({ tree });
|
||||
|
||||
expect(wrapper.findAllComponents(FileTree)).toHaveLength(1);
|
||||
expect(wrapper.findComponent(FileTree).props('file')).toEqual(tree[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty-branch state', () => {
|
||||
beforeEach(() => {
|
||||
bootstrapWithTree(emptyBranchTree);
|
||||
|
||||
jest.spyOn(vm, '$emit').mockImplementation(() => {});
|
||||
|
||||
vm.$mount();
|
||||
mountComponent({ tree: [] });
|
||||
});
|
||||
|
||||
it('still emits tree-ready event', () => {
|
||||
expect(vm.$emit).toHaveBeenCalledWith('tree-ready');
|
||||
it('emits tree-ready event', () => {
|
||||
expect(wrapper.emitted('tree-ready')).toEqual([[]]);
|
||||
});
|
||||
|
||||
it('does not load files if the branch is empty', () => {
|
||||
expect(vm.$el.textContent).not.toContain('fileName');
|
||||
expect(vm.$el.textContent).toContain('No files');
|
||||
it('does not render files', () => {
|
||||
expect(wrapper.findAllComponents(FileTree)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders empty state text', () => {
|
||||
expect(wrapper.text()).toBe('No files');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { GlTab } from '@gitlab/ui';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { editor as monacoEditor, Range } from 'monaco-editor';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
|
|
@ -125,7 +126,7 @@ describe('RepoEditor', () => {
|
|||
};
|
||||
|
||||
const findEditor = () => wrapper.find('[data-testid="editor-container"]');
|
||||
const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li');
|
||||
const findTabs = () => wrapper.findAllComponents(GlTab);
|
||||
const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]');
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -201,12 +202,12 @@ describe('RepoEditor', () => {
|
|||
const tabs = findTabs();
|
||||
|
||||
expect(tabs).toHaveLength(2);
|
||||
expect(tabs.at(0).text()).toBe('Edit');
|
||||
expect(tabs.at(1).text()).toBe('Preview Markdown');
|
||||
expect(tabs.at(0).element.dataset.testid).toBe('edit-tab');
|
||||
expect(tabs.at(1).element.dataset.testid).toBe('preview-tab');
|
||||
});
|
||||
|
||||
it('renders markdown for tempFile', async () => {
|
||||
findPreviewTab().trigger('click');
|
||||
findPreviewTab().vm.$emit('click');
|
||||
await waitForPromises();
|
||||
expect(wrapper.find(ContentViewer).html()).toContain(dummyFile.text.content);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_ro
|
|||
import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
|
||||
import IssuesListApp from '~/issues/list/components/issues_list_app.vue';
|
||||
import NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue';
|
||||
|
||||
import {
|
||||
CREATED_DESC,
|
||||
RELATIVE_POSITION,
|
||||
|
|
@ -98,6 +99,7 @@ describe('CE IssuesListApp component', () => {
|
|||
};
|
||||
|
||||
let defaultQueryResponse = getIssuesQueryResponse;
|
||||
let router;
|
||||
if (IS_EE) {
|
||||
defaultQueryResponse = cloneDeep(getIssuesQueryResponse);
|
||||
defaultQueryResponse.data.project.issues.nodes[0].blockingCount = 1;
|
||||
|
|
@ -133,9 +135,11 @@ describe('CE IssuesListApp component', () => {
|
|||
[setSortPreferenceMutation, sortPreferenceMutationResponse],
|
||||
];
|
||||
|
||||
router = new VueRouter({ mode: 'history' });
|
||||
|
||||
return mountFn(IssuesListApp, {
|
||||
apolloProvider: createMockApollo(requestHandlers),
|
||||
router: new VueRouter({ mode: 'history' }),
|
||||
router,
|
||||
provide: {
|
||||
...defaultProvide,
|
||||
...provide,
|
||||
|
|
@ -736,7 +740,7 @@ describe('CE IssuesListApp component', () => {
|
|||
describe('when "click-tab" event is emitted by IssuableList', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = mountComponent();
|
||||
jest.spyOn(wrapper.vm.$router, 'push');
|
||||
router.push = jest.fn();
|
||||
|
||||
findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
|
||||
});
|
||||
|
|
@ -746,16 +750,26 @@ describe('CE IssuesListApp component', () => {
|
|||
});
|
||||
|
||||
it('updates url to the new tab', () => {
|
||||
expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
|
||||
expect(router.push).toHaveBeenCalledWith({
|
||||
query: expect.objectContaining({ state: IssuableStates.Closed }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
event | params
|
||||
${'next-page'} | ${{ page_after: 'endCursor', page_before: undefined, first_page_size: 20, last_page_size: undefined }}
|
||||
${'previous-page'} | ${{ page_after: undefined, page_before: 'startCursor', first_page_size: undefined, last_page_size: 20 }}
|
||||
event | params
|
||||
${'next-page'} | ${{
|
||||
page_after: 'endCursor',
|
||||
page_before: undefined,
|
||||
first_page_size: 20,
|
||||
last_page_size: undefined,
|
||||
}}
|
||||
${'previous-page'} | ${{
|
||||
page_after: undefined,
|
||||
page_before: 'startCursor',
|
||||
first_page_size: undefined,
|
||||
last_page_size: 20,
|
||||
}}
|
||||
`('when "$event" event is emitted by IssuableList', ({ event, params }) => {
|
||||
beforeEach(() => {
|
||||
wrapper = mountComponent({
|
||||
|
|
@ -766,7 +780,7 @@ describe('CE IssuesListApp component', () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
jest.spyOn(wrapper.vm.$router, 'push');
|
||||
router.push = jest.fn();
|
||||
|
||||
findIssuableList().vm.$emit(event);
|
||||
});
|
||||
|
|
@ -776,7 +790,7 @@ describe('CE IssuesListApp component', () => {
|
|||
});
|
||||
|
||||
it(`updates url`, () => {
|
||||
expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
|
||||
expect(router.push).toHaveBeenCalledWith({
|
||||
query: expect.objectContaining(params),
|
||||
});
|
||||
});
|
||||
|
|
@ -888,13 +902,13 @@ describe('CE IssuesListApp component', () => {
|
|||
'updates to the new sort when payload is `%s`',
|
||||
async (sortKey) => {
|
||||
wrapper = mountComponent();
|
||||
jest.spyOn(wrapper.vm.$router, 'push');
|
||||
router.push = jest.fn();
|
||||
|
||||
findIssuableList().vm.$emit('sort', sortKey);
|
||||
jest.runOnlyPendingTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
|
||||
expect(router.push).toHaveBeenCalledWith({
|
||||
query: expect.objectContaining({ sort: urlSortParams[sortKey] }),
|
||||
});
|
||||
},
|
||||
|
|
@ -907,13 +921,13 @@ describe('CE IssuesListApp component', () => {
|
|||
wrapper = mountComponent({
|
||||
provide: { initialSort, isIssueRepositioningDisabled: true },
|
||||
});
|
||||
jest.spyOn(wrapper.vm.$router, 'push');
|
||||
router.push = jest.fn();
|
||||
|
||||
findIssuableList().vm.$emit('sort', RELATIVE_POSITION_ASC);
|
||||
});
|
||||
|
||||
it('does not update the sort to manual', () => {
|
||||
expect(wrapper.vm.$router.push).not.toHaveBeenCalled();
|
||||
expect(router.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an alert to tell the user that manual reordering is disabled', () => {
|
||||
|
|
@ -978,12 +992,12 @@ describe('CE IssuesListApp component', () => {
|
|||
describe('when "filter" event is emitted by IssuableList', () => {
|
||||
it('updates IssuableList with url params', async () => {
|
||||
wrapper = mountComponent();
|
||||
jest.spyOn(wrapper.vm.$router, 'push');
|
||||
router.push = jest.fn();
|
||||
|
||||
findIssuableList().vm.$emit('filter', filteredTokens);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
|
||||
expect(router.push).toHaveBeenCalledWith({
|
||||
query: expect.objectContaining(urlParams),
|
||||
});
|
||||
});
|
||||
|
|
@ -993,13 +1007,13 @@ describe('CE IssuesListApp component', () => {
|
|||
wrapper = mountComponent({
|
||||
provide: { isAnonymousSearchDisabled: true, isSignedIn: false },
|
||||
});
|
||||
jest.spyOn(wrapper.vm.$router, 'push');
|
||||
router.push = jest.fn();
|
||||
|
||||
findIssuableList().vm.$emit('filter', filteredTokens);
|
||||
});
|
||||
|
||||
it('does not update url params', () => {
|
||||
expect(wrapper.vm.$router.push).not.toHaveBeenCalled();
|
||||
expect(router.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an alert to tell the user they must be signed in to search', () => {
|
||||
|
|
@ -1030,4 +1044,19 @@ describe('CE IssuesListApp component', () => {
|
|||
expect(mockQuery).toHaveBeenCalledWith(expect.objectContaining({ hideUsers }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('when "page-size-change" event is emitted by IssuableList', () => {
|
||||
it('updates url params with new page size', async () => {
|
||||
wrapper = mountComponent();
|
||||
router.push = jest.fn();
|
||||
|
||||
findIssuableList().vm.$emit('page-size-change', 50);
|
||||
await nextTick();
|
||||
|
||||
expect(router.push).toHaveBeenCalledTimes(1);
|
||||
expect(router.push).toHaveBeenCalledWith({
|
||||
query: expect.objectContaining({ first_page_size: 50 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,12 +10,7 @@ import {
|
|||
urlParams,
|
||||
urlParamsWithSpecialValues,
|
||||
} from 'jest/issues/list/mock_data';
|
||||
import {
|
||||
PAGE_SIZE,
|
||||
PAGE_SIZE_MANUAL,
|
||||
RELATIVE_POSITION_ASC,
|
||||
urlSortParams,
|
||||
} from '~/issues/list/constants';
|
||||
import { PAGE_SIZE, urlSortParams } from '~/issues/list/constants';
|
||||
import {
|
||||
convertToApiParams,
|
||||
convertToSearchQuery,
|
||||
|
|
@ -29,52 +24,30 @@ import {
|
|||
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
|
||||
|
||||
describe('getInitialPageParams', () => {
|
||||
it.each(Object.keys(urlSortParams))(
|
||||
'returns the correct page params for sort key %s',
|
||||
(sortKey) => {
|
||||
const firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE;
|
||||
it('returns page params with a default page size when no arguments are given', () => {
|
||||
expect(getInitialPageParams()).toEqual({ firstPageSize: PAGE_SIZE });
|
||||
});
|
||||
|
||||
expect(getInitialPageParams(sortKey)).toEqual({ firstPageSize });
|
||||
},
|
||||
);
|
||||
it('returns page params with the given page size', () => {
|
||||
const pageSize = 100;
|
||||
expect(getInitialPageParams(pageSize)).toEqual({ firstPageSize: pageSize });
|
||||
});
|
||||
|
||||
it.each(Object.keys(urlSortParams))(
|
||||
'returns the correct page params for sort key %s with afterCursor',
|
||||
(sortKey) => {
|
||||
const firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE;
|
||||
const lastPageSize = undefined;
|
||||
const afterCursor = 'randomCursorString';
|
||||
const beforeCursor = undefined;
|
||||
const pageParams = getInitialPageParams(
|
||||
sortKey,
|
||||
firstPageSize,
|
||||
lastPageSize,
|
||||
afterCursor,
|
||||
beforeCursor,
|
||||
);
|
||||
it('does not return firstPageSize when lastPageSize is provided', () => {
|
||||
const firstPageSize = 100;
|
||||
const lastPageSize = 50;
|
||||
const afterCursor = undefined;
|
||||
const beforeCursor = 'randomCursorString';
|
||||
const pageParams = getInitialPageParams(
|
||||
100,
|
||||
firstPageSize,
|
||||
lastPageSize,
|
||||
afterCursor,
|
||||
beforeCursor,
|
||||
);
|
||||
|
||||
expect(pageParams).toEqual({ firstPageSize, afterCursor });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(Object.keys(urlSortParams))(
|
||||
'returns the correct page params for sort key %s with beforeCursor',
|
||||
(sortKey) => {
|
||||
const firstPageSize = undefined;
|
||||
const lastPageSize = PAGE_SIZE;
|
||||
const afterCursor = undefined;
|
||||
const beforeCursor = 'anotherRandomCursorString';
|
||||
const pageParams = getInitialPageParams(
|
||||
sortKey,
|
||||
firstPageSize,
|
||||
lastPageSize,
|
||||
afterCursor,
|
||||
beforeCursor,
|
||||
);
|
||||
|
||||
expect(pageParams).toEqual({ lastPageSize, beforeCursor });
|
||||
},
|
||||
);
|
||||
expect(pageParams).toEqual({ lastPageSize, beforeCursor });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSortKey', () => {
|
||||
|
|
|
|||
|
|
@ -1,49 +1,50 @@
|
|||
import Vue from 'vue';
|
||||
import edited from '~/issues/show/components/edited.vue';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import Edited from '~/issues/show/components/edited.vue';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
|
||||
function formatText(text) {
|
||||
return text.trim().replace(/\s\s+/g, ' ');
|
||||
}
|
||||
describe('Edited component', () => {
|
||||
let wrapper;
|
||||
|
||||
describe('edited', () => {
|
||||
const EditedComponent = Vue.extend(edited);
|
||||
const findAuthorLink = () => wrapper.find('a');
|
||||
const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
|
||||
const formatText = (text) => text.trim().replace(/\s\s+/g, ' ');
|
||||
|
||||
it('should render an edited at+by string', () => {
|
||||
const editedComponent = new EditedComponent({
|
||||
propsData: {
|
||||
updatedAt: '2017-05-15T12:31:04.428Z',
|
||||
updatedByName: 'Some User',
|
||||
updatedByPath: '/some_user',
|
||||
},
|
||||
}).$mount();
|
||||
const mountComponent = (propsData) => shallowMount(Edited, { propsData });
|
||||
|
||||
expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
|
||||
expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/);
|
||||
expect(editedComponent.$el.querySelector('time')).toBeTruthy();
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders an edited at+by string', () => {
|
||||
wrapper = mountComponent({
|
||||
updatedAt: '2017-05-15T12:31:04.428Z',
|
||||
updatedByName: 'Some User',
|
||||
updatedByPath: '/some_user',
|
||||
});
|
||||
|
||||
expect(formatText(wrapper.text())).toBe('Edited by Some User');
|
||||
expect(findAuthorLink().attributes('href')).toBe('/some_user');
|
||||
expect(findTimeAgoTooltip().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('if no updatedAt is provided, no time element will be rendered', () => {
|
||||
const editedComponent = new EditedComponent({
|
||||
propsData: {
|
||||
updatedByName: 'Some User',
|
||||
updatedByPath: '/some_user',
|
||||
},
|
||||
}).$mount();
|
||||
wrapper = mountComponent({
|
||||
updatedByName: 'Some User',
|
||||
updatedByPath: '/some_user',
|
||||
});
|
||||
|
||||
expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited by Some User/);
|
||||
expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/);
|
||||
expect(editedComponent.$el.querySelector('time')).toBeFalsy();
|
||||
expect(formatText(wrapper.text())).toBe('Edited by Some User');
|
||||
expect(findAuthorLink().attributes('href')).toBe('/some_user');
|
||||
expect(findTimeAgoTooltip().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => {
|
||||
const editedComponent = new EditedComponent({
|
||||
propsData: {
|
||||
updatedAt: '2017-05-15T12:31:04.428Z',
|
||||
},
|
||||
}).$mount();
|
||||
wrapper = mountComponent({
|
||||
updatedAt: '2017-05-15T12:31:04.428Z',
|
||||
});
|
||||
|
||||
expect(formatText(editedComponent.$el.innerText)).not.toMatch(/by Some User/);
|
||||
expect(editedComponent.$el.querySelector('.author-link')).toBeFalsy();
|
||||
expect(editedComponent.$el.querySelector('time')).toBeTruthy();
|
||||
expect(formatText(wrapper.text())).toBe('Edited');
|
||||
expect(findAuthorLink().exists()).toBe(false);
|
||||
expect(findTimeAgoTooltip().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,370 +0,0 @@
|
|||
import { GlSprintf, GlDropdown, GlDropdownItem } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { scrollDown } from '~/lib/utils/scroll_utils';
|
||||
import EnvironmentLogs from '~/logs/components/environment_logs.vue';
|
||||
|
||||
import { createStore } from '~/logs/stores';
|
||||
import {
|
||||
mockEnvName,
|
||||
mockEnvironments,
|
||||
mockPods,
|
||||
mockLogsResult,
|
||||
mockTrace,
|
||||
mockEnvironmentsEndpoint,
|
||||
mockDocumentationPath,
|
||||
} from '../mock_data';
|
||||
|
||||
jest.mock('~/lib/utils/scroll_utils');
|
||||
|
||||
const module = 'environmentLogs';
|
||||
|
||||
jest.mock('lodash/throttle', () =>
|
||||
jest.fn((func) => {
|
||||
return func;
|
||||
}),
|
||||
);
|
||||
|
||||
describe('EnvironmentLogs', () => {
|
||||
let store;
|
||||
let dispatch;
|
||||
let wrapper;
|
||||
let state;
|
||||
|
||||
const propsData = {
|
||||
environmentName: mockEnvName,
|
||||
environmentsPath: mockEnvironmentsEndpoint,
|
||||
clusterApplicationsDocumentationPath: mockDocumentationPath,
|
||||
clustersPath: '/gitlab-org',
|
||||
};
|
||||
|
||||
const updateControlBtnsMock = jest.fn();
|
||||
const LogControlButtonsStub = {
|
||||
template: '<div/>',
|
||||
methods: {
|
||||
update: updateControlBtnsMock,
|
||||
},
|
||||
props: {
|
||||
scrollDownButtonDisabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const findEnvironmentsDropdown = () => wrapper.find('.js-environments-dropdown');
|
||||
|
||||
const findSimpleFilters = () => wrapper.find({ ref: 'log-simple-filters' });
|
||||
const findAdvancedFilters = () => wrapper.find({ ref: 'log-advanced-filters' });
|
||||
const findElasticsearchNotice = () => wrapper.find({ ref: 'elasticsearchNotice' });
|
||||
const findLogControlButtons = () => wrapper.find(LogControlButtonsStub);
|
||||
|
||||
const findInfiniteScroll = () => wrapper.find({ ref: 'infiniteScroll' });
|
||||
const findLogTrace = () => wrapper.find({ ref: 'logTrace' });
|
||||
const findLogFooter = () => wrapper.find({ ref: 'logFooter' });
|
||||
const getInfiniteScrollAttr = (attr) => parseInt(findInfiniteScroll().attributes(attr), 10);
|
||||
|
||||
const mockSetInitData = () => {
|
||||
state.pods.options = mockPods;
|
||||
state.environments.current = mockEnvName;
|
||||
[state.pods.current] = state.pods.options;
|
||||
|
||||
state.logs.lines = [];
|
||||
};
|
||||
|
||||
const mockShowPodLogs = () => {
|
||||
state.pods.options = mockPods;
|
||||
[state.pods.current] = mockPods;
|
||||
|
||||
state.logs.lines = mockLogsResult;
|
||||
};
|
||||
|
||||
const mockFetchEnvs = () => {
|
||||
state.environments.options = mockEnvironments;
|
||||
};
|
||||
|
||||
const initWrapper = () => {
|
||||
wrapper = shallowMount(EnvironmentLogs, {
|
||||
propsData,
|
||||
store,
|
||||
stubs: {
|
||||
LogControlButtons: LogControlButtonsStub,
|
||||
GlInfiniteScroll: {
|
||||
name: 'gl-infinite-scroll',
|
||||
template: `
|
||||
<div>
|
||||
<slot name="header"></slot>
|
||||
<slot name="items"></slot>
|
||||
<slot></slot>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
GlSprintf,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
state = store.state.environmentLogs;
|
||||
|
||||
jest.spyOn(store, 'dispatch').mockResolvedValue();
|
||||
|
||||
dispatch = store.dispatch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.dispatch.mockReset();
|
||||
|
||||
if (wrapper) {
|
||||
wrapper.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('displays UI elements', () => {
|
||||
initWrapper();
|
||||
|
||||
expect(findEnvironmentsDropdown().is(GlDropdown)).toBe(true);
|
||||
expect(findSimpleFilters().exists()).toBe(true);
|
||||
expect(findLogControlButtons().exists()).toBe(true);
|
||||
|
||||
expect(findInfiniteScroll().exists()).toBe(true);
|
||||
expect(findLogTrace().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('mounted inits data', () => {
|
||||
initWrapper();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(`${module}/setInitData`, {
|
||||
timeRange: expect.objectContaining({
|
||||
default: true,
|
||||
}),
|
||||
environmentName: mockEnvName,
|
||||
podName: null,
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(`${module}/fetchEnvironments`, mockEnvironmentsEndpoint);
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
beforeEach(() => {
|
||||
state.pods.options = [];
|
||||
|
||||
state.logs.lines = [];
|
||||
state.logs.isLoading = true;
|
||||
|
||||
state.environments = {
|
||||
options: [],
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
initWrapper();
|
||||
});
|
||||
|
||||
it('does not display an alert to upgrade to ES', () => {
|
||||
expect(findElasticsearchNotice().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('displays a disabled environments dropdown', () => {
|
||||
expect(findEnvironmentsDropdown().attributes('disabled')).toBe('true');
|
||||
expect(findEnvironmentsDropdown().findAll(GlDropdownItem).length).toBe(0);
|
||||
});
|
||||
|
||||
it('does not update buttons state', () => {
|
||||
expect(updateControlBtnsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an infinite scroll with no content', () => {
|
||||
expect(getInfiniteScrollAttr('fetched-items')).toBe(0);
|
||||
});
|
||||
|
||||
it('shows an infinite scroll container with no set max-height ', () => {
|
||||
expect(findInfiniteScroll().attributes('max-list-height')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows a logs trace', () => {
|
||||
expect(findLogTrace().text()).toBe('');
|
||||
expect(findLogTrace().find('.js-build-loader-animation').isVisible()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('k8s environment', () => {
|
||||
beforeEach(() => {
|
||||
state.pods.options = [];
|
||||
|
||||
state.logs.lines = [];
|
||||
state.logs.isLoading = false;
|
||||
|
||||
state.environments = {
|
||||
options: mockEnvironments,
|
||||
current: 'staging',
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
initWrapper();
|
||||
});
|
||||
|
||||
it('displays an alert to upgrade to ES', () => {
|
||||
expect(findElasticsearchNotice().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('displays simple filters for kubernetes logs API', () => {
|
||||
expect(findSimpleFilters().exists()).toBe(true);
|
||||
expect(findAdvancedFilters().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state with data', () => {
|
||||
beforeEach(() => {
|
||||
dispatch.mockImplementation((actionName) => {
|
||||
if (actionName === `${module}/setInitData`) {
|
||||
mockSetInitData();
|
||||
} else if (actionName === `${module}/showPodLogs`) {
|
||||
mockShowPodLogs();
|
||||
} else if (actionName === `${module}/fetchEnvironments`) {
|
||||
mockFetchEnvs();
|
||||
mockShowPodLogs();
|
||||
}
|
||||
});
|
||||
|
||||
initWrapper();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scrollDown.mockReset();
|
||||
updateControlBtnsMock.mockReset();
|
||||
});
|
||||
|
||||
it('does not display an alert to upgrade to ES', () => {
|
||||
expect(findElasticsearchNotice().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('populates environments dropdown', () => {
|
||||
const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
|
||||
expect(findEnvironmentsDropdown().props('text')).toBe(mockEnvName);
|
||||
expect(items.length).toBe(mockEnvironments.length);
|
||||
mockEnvironments.forEach((env, i) => {
|
||||
const item = items.at(i);
|
||||
expect(item.text()).toBe(env.name);
|
||||
});
|
||||
});
|
||||
|
||||
it('dropdown has one environment selected', () => {
|
||||
const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
|
||||
mockEnvironments.forEach((env, i) => {
|
||||
const item = items.at(i);
|
||||
|
||||
if (item.text() !== mockEnvName) {
|
||||
expect(item.find(GlDropdownItem).attributes('ischecked')).toBeFalsy();
|
||||
} else {
|
||||
expect(item.find(GlDropdownItem).attributes('ischecked')).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('displays advanced filters for elasticsearch logs API', () => {
|
||||
expect(findSimpleFilters().exists()).toBe(false);
|
||||
expect(findAdvancedFilters().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows infinite scroll with content', () => {
|
||||
expect(getInfiniteScrollAttr('fetched-items')).toBe(mockTrace.length);
|
||||
});
|
||||
|
||||
it('populates logs trace', () => {
|
||||
const trace = findLogTrace();
|
||||
expect(trace.text().split('\n').length).toBe(mockTrace.length);
|
||||
expect(trace.text().split('\n')).toEqual(mockTrace);
|
||||
});
|
||||
|
||||
it('populates footer', () => {
|
||||
const footer = findLogFooter().text();
|
||||
|
||||
expect(footer).toContain(`${mockLogsResult.length} results`);
|
||||
});
|
||||
|
||||
describe('when user clicks', () => {
|
||||
it('environment name, trace is refreshed', () => {
|
||||
const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
|
||||
const index = 1; // any env
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalledWith(`${module}/showEnvironment`, expect.anything());
|
||||
|
||||
items.at(index).vm.$emit('click');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
`${module}/showEnvironment`,
|
||||
mockEnvironments[index].name,
|
||||
);
|
||||
});
|
||||
|
||||
it('refresh button, trace is refreshed', () => {
|
||||
expect(dispatch).not.toHaveBeenCalledWith(`${module}/refreshPodLogs`, undefined);
|
||||
|
||||
findLogControlButtons().vm.$emit('refresh');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(`${module}/refreshPodLogs`, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listeners', () => {
|
||||
beforeEach(() => {
|
||||
initWrapper();
|
||||
});
|
||||
|
||||
it('attaches listeners in components', () => {
|
||||
expect(findInfiniteScroll().vm.$listeners).toEqual({
|
||||
topReached: expect.any(Function),
|
||||
scroll: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('`topReached` when not loading', () => {
|
||||
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
|
||||
|
||||
findInfiniteScroll().vm.$emit('topReached');
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
|
||||
});
|
||||
|
||||
it('`topReached` does not fetches more logs when already loading', () => {
|
||||
state.logs.isLoading = true;
|
||||
findInfiniteScroll().vm.$emit('topReached');
|
||||
|
||||
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
|
||||
});
|
||||
|
||||
it('`topReached` fetches more logs', () => {
|
||||
state.logs.isLoading = true;
|
||||
findInfiniteScroll().vm.$emit('topReached');
|
||||
|
||||
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
|
||||
});
|
||||
|
||||
it('`scroll` on a scrollable target results in enabled scroll buttons', async () => {
|
||||
const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 21 };
|
||||
|
||||
state.logs.isLoading = true;
|
||||
findInfiniteScroll().vm.$emit('scroll', { target });
|
||||
|
||||
await nextTick();
|
||||
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(false);
|
||||
});
|
||||
|
||||
it('`scroll` on a non-scrollable target in disabled scroll buttons', async () => {
|
||||
const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 20 };
|
||||
|
||||
state.logs.isLoading = true;
|
||||
findInfiniteScroll().vm.$emit('scroll', { target });
|
||||
|
||||
await nextTick();
|
||||
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true);
|
||||
});
|
||||
|
||||
it('`scroll` on no target results in disabled scroll buttons', async () => {
|
||||
state.logs.isLoading = true;
|
||||
findInfiniteScroll().vm.$emit('scroll', { target: undefined });
|
||||
|
||||
await nextTick();
|
||||
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
import { GlFilteredSearch } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { convertToFixedRange } from '~/lib/utils/datetime_range';
|
||||
import LogAdvancedFilters from '~/logs/components/log_advanced_filters.vue';
|
||||
import { TOKEN_TYPE_POD_NAME } from '~/logs/constants';
|
||||
import { createStore } from '~/logs/stores';
|
||||
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
|
||||
import { defaultTimeRange } from '~/vue_shared/constants';
|
||||
import { mockPods, mockSearch } from '../mock_data';
|
||||
|
||||
const module = 'environmentLogs';
|
||||
|
||||
describe('LogAdvancedFilters', () => {
|
||||
let store;
|
||||
let dispatch;
|
||||
let wrapper;
|
||||
let state;
|
||||
|
||||
const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
|
||||
const findTimeRangePicker = () => wrapper.find({ ref: 'dateTimePicker' });
|
||||
const getSearchToken = (type) =>
|
||||
findFilteredSearch()
|
||||
.props('availableTokens')
|
||||
.filter((token) => token.type === type)[0];
|
||||
|
||||
const mockStateLoading = () => {
|
||||
state.timeRange.selected = defaultTimeRange;
|
||||
state.timeRange.current = convertToFixedRange(defaultTimeRange);
|
||||
state.pods.options = [];
|
||||
state.pods.current = null;
|
||||
state.logs.isLoading = true;
|
||||
};
|
||||
|
||||
const mockStateWithData = () => {
|
||||
state.timeRange.selected = defaultTimeRange;
|
||||
state.timeRange.current = convertToFixedRange(defaultTimeRange);
|
||||
state.pods.options = mockPods;
|
||||
state.pods.current = null;
|
||||
state.logs.isLoading = false;
|
||||
};
|
||||
|
||||
const initWrapper = (propsData = {}) => {
|
||||
wrapper = shallowMount(LogAdvancedFilters, {
|
||||
propsData: {
|
||||
...propsData,
|
||||
},
|
||||
store,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
state = store.state.environmentLogs;
|
||||
|
||||
jest.spyOn(store, 'dispatch').mockResolvedValue();
|
||||
|
||||
dispatch = store.dispatch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.dispatch.mockReset();
|
||||
|
||||
if (wrapper) {
|
||||
wrapper.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('displays UI elements', () => {
|
||||
initWrapper();
|
||||
|
||||
expect(findFilteredSearch().exists()).toBe(true);
|
||||
expect(findTimeRangePicker().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('displays search tokens', () => {
|
||||
initWrapper();
|
||||
|
||||
expect(getSearchToken(TOKEN_TYPE_POD_NAME)).toMatchObject({
|
||||
title: 'Pod name',
|
||||
unique: true,
|
||||
operators: OPERATOR_IS_ONLY,
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled state', () => {
|
||||
beforeEach(() => {
|
||||
mockStateLoading();
|
||||
initWrapper({
|
||||
disabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('displays disabled filters', () => {
|
||||
expect(findFilteredSearch().attributes('disabled')).toBeTruthy();
|
||||
expect(findTimeRangePicker().attributes('disabled')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the state is loading', () => {
|
||||
beforeEach(() => {
|
||||
mockStateLoading();
|
||||
initWrapper();
|
||||
});
|
||||
|
||||
it('displays a disabled search', () => {
|
||||
expect(findFilteredSearch().attributes('disabled')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('displays an enable date filter', () => {
|
||||
expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('displays no pod options when no pods are available, so suggestions can be displayed', () => {
|
||||
expect(getSearchToken(TOKEN_TYPE_POD_NAME).options).toBe(null);
|
||||
expect(getSearchToken(TOKEN_TYPE_POD_NAME).loading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the state has data', () => {
|
||||
beforeEach(() => {
|
||||
mockStateWithData();
|
||||
initWrapper();
|
||||
});
|
||||
|
||||
it('displays a single token for pods', () => {
|
||||
initWrapper();
|
||||
|
||||
const tokens = findFilteredSearch().props('availableTokens');
|
||||
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(tokens[0].type).toBe(TOKEN_TYPE_POD_NAME);
|
||||
});
|
||||
|
||||
it('displays a enabled filters', () => {
|
||||
expect(findFilteredSearch().attributes('disabled')).toBeFalsy();
|
||||
expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('displays options in the pods token', () => {
|
||||
const { options } = getSearchToken(TOKEN_TYPE_POD_NAME);
|
||||
|
||||
expect(options).toHaveLength(mockPods.length);
|
||||
});
|
||||
|
||||
it('displays options in date time picker', () => {
|
||||
const options = findTimeRangePicker().props('options');
|
||||
|
||||
expect(options).toEqual(expect.any(Array));
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe('when the user interacts', () => {
|
||||
it('clicks on the search button, showFilteredLogs is dispatched', () => {
|
||||
findFilteredSearch().vm.$emit('submit', null);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, null);
|
||||
});
|
||||
|
||||
it('clicks on the search button, showFilteredLogs is dispatched with null', () => {
|
||||
findFilteredSearch().vm.$emit('submit', [mockSearch]);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, [mockSearch]);
|
||||
});
|
||||
|
||||
it('selects a new time range', () => {
|
||||
expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
|
||||
|
||||
const mockRange = { start: 'START_DATE', end: 'END_DATE' };
|
||||
findTimeRangePicker().vm.$emit('input', mockRange);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(`${module}/setTimeRange`, mockRange);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import { GlDropdownItem } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import LogSimpleFilters from '~/logs/components/log_simple_filters.vue';
|
||||
import { createStore } from '~/logs/stores';
|
||||
import { mockPods, mockPodName } from '../mock_data';
|
||||
|
||||
const module = 'environmentLogs';
|
||||
|
||||
describe('LogSimpleFilters', () => {
|
||||
let store;
|
||||
let dispatch;
|
||||
let wrapper;
|
||||
let state;
|
||||
|
||||
const findPodsDropdown = () => wrapper.find({ ref: 'podsDropdown' });
|
||||
const findPodsNoPodsText = () => wrapper.find({ ref: 'noPodsMsg' });
|
||||
const findPodsDropdownItems = () =>
|
||||
findPodsDropdown()
|
||||
.findAll(GlDropdownItem)
|
||||
.filter((item) => !('disabled' in item.attributes()));
|
||||
|
||||
const mockPodsLoading = () => {
|
||||
state.pods.options = [];
|
||||
state.pods.current = null;
|
||||
};
|
||||
|
||||
const mockPodsLoaded = () => {
|
||||
state.pods.options = mockPods;
|
||||
state.pods.current = mockPodName;
|
||||
};
|
||||
|
||||
const initWrapper = (propsData = {}) => {
|
||||
wrapper = shallowMount(LogSimpleFilters, {
|
||||
propsData: {
|
||||
...propsData,
|
||||
},
|
||||
store,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
state = store.state.environmentLogs;
|
||||
|
||||
jest.spyOn(store, 'dispatch').mockResolvedValue();
|
||||
|
||||
dispatch = store.dispatch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.dispatch.mockReset();
|
||||
|
||||
if (wrapper) {
|
||||
wrapper.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('displays UI elements', () => {
|
||||
initWrapper();
|
||||
|
||||
expect(findPodsDropdown().exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('disabled state', () => {
|
||||
beforeEach(() => {
|
||||
mockPodsLoading();
|
||||
initWrapper({
|
||||
disabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('displays a disabled pods dropdown', () => {
|
||||
expect(findPodsDropdown().props('text')).toBe('No pod selected');
|
||||
expect(findPodsDropdown().attributes('disabled')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
beforeEach(() => {
|
||||
mockPodsLoading();
|
||||
initWrapper();
|
||||
});
|
||||
|
||||
it('displays an enabled pods dropdown', () => {
|
||||
expect(findPodsDropdown().attributes('disabled')).toBeFalsy();
|
||||
expect(findPodsDropdown().props('text')).toBe('No pod selected');
|
||||
});
|
||||
|
||||
it('displays an empty pods dropdown', () => {
|
||||
expect(findPodsNoPodsText().exists()).toBe(true);
|
||||
expect(findPodsDropdownItems()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pods available state', () => {
|
||||
beforeEach(() => {
|
||||
mockPodsLoaded();
|
||||
initWrapper();
|
||||
});
|
||||
|
||||
it('displays an enabled pods dropdown', () => {
|
||||
expect(findPodsDropdown().attributes('disabled')).toBeFalsy();
|
||||
expect(findPodsDropdown().props('text')).toBe(mockPods[0]);
|
||||
});
|
||||
|
||||
it('displays a pods dropdown with items', () => {
|
||||
expect(findPodsNoPodsText().exists()).toBe(false);
|
||||
expect(findPodsDropdownItems()).toHaveLength(mockPods.length);
|
||||
});
|
||||
|
||||
it('dropdown has one pod selected', () => {
|
||||
const items = findPodsDropdownItems();
|
||||
mockPods.forEach((pod, i) => {
|
||||
const item = items.at(i);
|
||||
if (item.text() !== mockPodName) {
|
||||
expect(item.find(GlDropdownItem).attributes('ischecked')).toBeFalsy();
|
||||
} else {
|
||||
expect(item.find(GlDropdownItem).attributes('ischecked')).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('when the user clicks on a pod, showPodLogs is dispatched', () => {
|
||||
const items = findPodsDropdownItems();
|
||||
const index = 2; // any pod
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalledWith(`${module}/showPodLogs`, expect.anything());
|
||||
|
||||
items.at(index).vm.$emit('click');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPods[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,521 +0,0 @@
|
|||
import MockAdapter from 'axios-mock-adapter';
|
||||
import testAction from 'helpers/vuex_action_helper';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { convertToFixedRange } from '~/lib/utils/datetime_range';
|
||||
import { TOKEN_TYPE_POD_NAME } from '~/logs/constants';
|
||||
import {
|
||||
setInitData,
|
||||
showFilteredLogs,
|
||||
showPodLogs,
|
||||
fetchEnvironments,
|
||||
fetchLogs,
|
||||
fetchMoreLogsPrepend,
|
||||
} from '~/logs/stores/actions';
|
||||
import * as types from '~/logs/stores/mutation_types';
|
||||
import logsPageState from '~/logs/stores/state';
|
||||
import Tracking from '~/tracking';
|
||||
|
||||
import { defaultTimeRange } from '~/vue_shared/constants';
|
||||
|
||||
import {
|
||||
mockPodName,
|
||||
mockEnvironmentsEndpoint,
|
||||
mockEnvironments,
|
||||
mockPods,
|
||||
mockLogsResult,
|
||||
mockEnvName,
|
||||
mockSearch,
|
||||
mockLogsEndpoint,
|
||||
mockResponse,
|
||||
mockCursor,
|
||||
mockNextCursor,
|
||||
} from '../mock_data';
|
||||
|
||||
jest.mock('~/lib/utils/datetime_range');
|
||||
jest.mock('~/logs/utils');
|
||||
|
||||
const mockDefaultRange = {
|
||||
start: '2020-01-10T18:00:00.000Z',
|
||||
end: '2020-01-10T19:00:00.000Z',
|
||||
};
|
||||
const mockFixedRange = {
|
||||
start: '2020-01-09T18:06:20.000Z',
|
||||
end: '2020-01-09T18:36:20.000Z',
|
||||
};
|
||||
const mockRollingRange = {
|
||||
duration: 120,
|
||||
};
|
||||
const mockRollingRangeAsFixed = {
|
||||
start: '2020-01-10T18:00:00.000Z',
|
||||
end: '2020-01-10T17:58:00.000Z',
|
||||
};
|
||||
|
||||
describe('Logs Store actions', () => {
|
||||
let state;
|
||||
let mock;
|
||||
|
||||
const latestGetParams = () => mock.history.get[mock.history.get.length - 1].params;
|
||||
|
||||
convertToFixedRange.mockImplementation((range) => {
|
||||
if (range === defaultTimeRange) {
|
||||
return { ...mockDefaultRange };
|
||||
}
|
||||
if (range === mockFixedRange) {
|
||||
return { ...mockFixedRange };
|
||||
}
|
||||
if (range === mockRollingRange) {
|
||||
return { ...mockRollingRangeAsFixed };
|
||||
}
|
||||
throw new Error('Invalid time range');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
state = logsPageState();
|
||||
});
|
||||
|
||||
describe('setInitData', () => {
|
||||
it('should commit environment and pod name mutation', () =>
|
||||
testAction(
|
||||
setInitData,
|
||||
{ timeRange: mockFixedRange, environmentName: mockEnvName, podName: mockPodName },
|
||||
state,
|
||||
[
|
||||
{ type: types.SET_TIME_RANGE, payload: mockFixedRange },
|
||||
{ type: types.SET_PROJECT_ENVIRONMENT, payload: mockEnvName },
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||
],
|
||||
));
|
||||
});
|
||||
|
||||
describe('showFilteredLogs', () => {
|
||||
it('empty search should filter with defaults', () =>
|
||||
testAction(
|
||||
showFilteredLogs,
|
||||
undefined,
|
||||
state,
|
||||
[
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: null },
|
||||
{ type: types.SET_SEARCH, payload: '' },
|
||||
],
|
||||
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
|
||||
));
|
||||
|
||||
it('text search should filter with a search term', () =>
|
||||
testAction(
|
||||
showFilteredLogs,
|
||||
[mockSearch],
|
||||
state,
|
||||
[
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: null },
|
||||
{ type: types.SET_SEARCH, payload: mockSearch },
|
||||
],
|
||||
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
|
||||
));
|
||||
|
||||
it('pod search should filter with a search term', () =>
|
||||
testAction(
|
||||
showFilteredLogs,
|
||||
[{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }],
|
||||
state,
|
||||
[
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||
{ type: types.SET_SEARCH, payload: '' },
|
||||
],
|
||||
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
|
||||
));
|
||||
|
||||
it('pod search should filter with a pod selection and a search term', () =>
|
||||
testAction(
|
||||
showFilteredLogs,
|
||||
[{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }, mockSearch],
|
||||
state,
|
||||
[
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||
{ type: types.SET_SEARCH, payload: mockSearch },
|
||||
],
|
||||
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
|
||||
));
|
||||
|
||||
it('pod search should filter with a pod selection and two search terms', () =>
|
||||
testAction(
|
||||
showFilteredLogs,
|
||||
['term1', 'term2'],
|
||||
state,
|
||||
[
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: null },
|
||||
{ type: types.SET_SEARCH, payload: `term1 term2` },
|
||||
],
|
||||
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
|
||||
));
|
||||
|
||||
it('pod search should filter with a pod selection and a search terms before and after', () =>
|
||||
testAction(
|
||||
showFilteredLogs,
|
||||
[
|
||||
'term1',
|
||||
{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } },
|
||||
'term2',
|
||||
],
|
||||
state,
|
||||
[
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||
{ type: types.SET_SEARCH, payload: `term1 term2` },
|
||||
],
|
||||
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
|
||||
));
|
||||
});
|
||||
|
||||
describe('showPodLogs', () => {
|
||||
it('should commit pod name', () =>
|
||||
testAction(
|
||||
showPodLogs,
|
||||
mockPodName,
|
||||
state,
|
||||
[{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName }],
|
||||
[{ type: 'fetchLogs', payload: 'pod_log_changed' }],
|
||||
));
|
||||
});
|
||||
|
||||
describe('fetchEnvironments', () => {
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(axios);
|
||||
});
|
||||
|
||||
it('should commit RECEIVE_ENVIRONMENTS_DATA_SUCCESS mutation on correct data', () => {
|
||||
mock.onGet(mockEnvironmentsEndpoint).replyOnce(200, mockEnvironments);
|
||||
return testAction(
|
||||
fetchEnvironments,
|
||||
mockEnvironmentsEndpoint,
|
||||
state,
|
||||
[
|
||||
{ type: types.REQUEST_ENVIRONMENTS_DATA },
|
||||
{ type: types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, payload: mockEnvironments },
|
||||
],
|
||||
[{ type: 'fetchLogs', payload: 'environment_selected' }],
|
||||
);
|
||||
});
|
||||
|
||||
it('should commit RECEIVE_ENVIRONMENTS_DATA_ERROR on wrong data', () => {
|
||||
mock.onGet(mockEnvironmentsEndpoint).replyOnce(500);
|
||||
return testAction(
|
||||
fetchEnvironments,
|
||||
mockEnvironmentsEndpoint,
|
||||
state,
|
||||
[
|
||||
{ type: types.REQUEST_ENVIRONMENTS_DATA },
|
||||
{ type: types.RECEIVE_ENVIRONMENTS_DATA_ERROR },
|
||||
],
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the backend responds succesfully', () => {
|
||||
let expectedMutations;
|
||||
let expectedActions;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(axios);
|
||||
mock.onGet(mockLogsEndpoint).reply(200, mockResponse);
|
||||
mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
|
||||
|
||||
state.environments.options = mockEnvironments;
|
||||
state.environments.current = mockEnvName;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe('fetchLogs', () => {
|
||||
beforeEach(() => {
|
||||
expectedMutations = [
|
||||
{ type: types.REQUEST_LOGS_DATA },
|
||||
{
|
||||
type: types.RECEIVE_LOGS_DATA_SUCCESS,
|
||||
payload: { logs: mockLogsResult, cursor: mockNextCursor },
|
||||
},
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
|
||||
];
|
||||
|
||||
expectedActions = [];
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when there is pod name defined', () => {
|
||||
state.pods.current = mockPodName;
|
||||
state.timeRange.current = mockFixedRange;
|
||||
|
||||
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
|
||||
expect(latestGetParams()).toMatchObject({
|
||||
pod_name: mockPodName,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
|
||||
state.pods.current = mockPodName;
|
||||
state.timeRange.current = mockFixedRange;
|
||||
state.logs.cursor = mockCursor;
|
||||
|
||||
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
|
||||
expect(latestGetParams()).toEqual({
|
||||
pod_name: mockPodName,
|
||||
start_time: mockFixedRange.start,
|
||||
end_time: mockFixedRange.end,
|
||||
cursor: mockCursor,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
|
||||
state.pods.current = mockPodName;
|
||||
state.search = mockSearch;
|
||||
state.timeRange.current = 'INVALID_TIME_RANGE';
|
||||
|
||||
expectedMutations.splice(1, 0, {
|
||||
type: types.SHOW_TIME_RANGE_INVALID_WARNING,
|
||||
});
|
||||
|
||||
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
|
||||
expect(latestGetParams()).toEqual({
|
||||
pod_name: mockPodName,
|
||||
search: mockSearch,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when no pod name defined', () => {
|
||||
state.timeRange.current = defaultTimeRange;
|
||||
|
||||
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
|
||||
expect(latestGetParams()).toEqual({
|
||||
start_time: expect.any(String),
|
||||
end_time: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchMoreLogsPrepend', () => {
|
||||
beforeEach(() => {
|
||||
expectedMutations = [
|
||||
{ type: types.REQUEST_LOGS_DATA_PREPEND },
|
||||
{
|
||||
type: types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS,
|
||||
payload: { logs: mockLogsResult, cursor: mockNextCursor },
|
||||
},
|
||||
];
|
||||
|
||||
expectedActions = [];
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when there is pod name defined', () => {
|
||||
state.pods.current = mockPodName;
|
||||
state.timeRange.current = mockFixedRange;
|
||||
|
||||
expectedActions = [];
|
||||
|
||||
return testAction(
|
||||
fetchMoreLogsPrepend,
|
||||
null,
|
||||
state,
|
||||
expectedMutations,
|
||||
expectedActions,
|
||||
() => {
|
||||
expect(latestGetParams()).toMatchObject({
|
||||
pod_name: mockPodName,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
|
||||
state.pods.current = mockPodName;
|
||||
state.timeRange.current = mockFixedRange;
|
||||
state.logs.cursor = mockCursor;
|
||||
|
||||
return testAction(
|
||||
fetchMoreLogsPrepend,
|
||||
null,
|
||||
state,
|
||||
expectedMutations,
|
||||
expectedActions,
|
||||
() => {
|
||||
expect(latestGetParams()).toEqual({
|
||||
pod_name: mockPodName,
|
||||
start_time: mockFixedRange.start,
|
||||
end_time: mockFixedRange.end,
|
||||
cursor: mockCursor,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
|
||||
state.pods.current = mockPodName;
|
||||
state.search = mockSearch;
|
||||
state.timeRange.current = 'INVALID_TIME_RANGE';
|
||||
|
||||
expectedMutations.splice(1, 0, {
|
||||
type: types.SHOW_TIME_RANGE_INVALID_WARNING,
|
||||
});
|
||||
|
||||
return testAction(
|
||||
fetchMoreLogsPrepend,
|
||||
null,
|
||||
state,
|
||||
expectedMutations,
|
||||
expectedActions,
|
||||
() => {
|
||||
expect(latestGetParams()).toEqual({
|
||||
pod_name: mockPodName,
|
||||
search: mockSearch,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when no pod name defined', () => {
|
||||
state.timeRange.current = defaultTimeRange;
|
||||
|
||||
return testAction(
|
||||
fetchMoreLogsPrepend,
|
||||
null,
|
||||
state,
|
||||
expectedMutations,
|
||||
expectedActions,
|
||||
() => {
|
||||
expect(latestGetParams()).toEqual({
|
||||
start_time: expect.any(String),
|
||||
end_time: expect.any(String),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should not commit logs or pod data when it has reached the end', () => {
|
||||
state.logs.isComplete = true;
|
||||
state.logs.cursor = null;
|
||||
|
||||
return testAction(
|
||||
fetchMoreLogsPrepend,
|
||||
null,
|
||||
state,
|
||||
[], // no mutations done
|
||||
[], // no actions dispatched
|
||||
() => {
|
||||
expect(mock.history.get).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the backend responds with an error', () => {
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(axios);
|
||||
mock.onGet(mockLogsEndpoint).reply(500);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
it('fetchLogs should commit logs and pod errors', () => {
|
||||
state.environments.options = mockEnvironments;
|
||||
state.environments.current = mockEnvName;
|
||||
state.timeRange.current = defaultTimeRange;
|
||||
|
||||
return testAction(
|
||||
fetchLogs,
|
||||
null,
|
||||
state,
|
||||
[
|
||||
{ type: types.REQUEST_LOGS_DATA },
|
||||
{ type: types.RECEIVE_PODS_DATA_ERROR },
|
||||
{ type: types.RECEIVE_LOGS_DATA_ERROR },
|
||||
],
|
||||
[],
|
||||
() => {
|
||||
expect(mock.history.get[0].url).toBe(mockLogsEndpoint);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('fetchMoreLogsPrepend should commit logs and pod errors', () => {
|
||||
state.environments.options = mockEnvironments;
|
||||
state.environments.current = mockEnvName;
|
||||
state.timeRange.current = defaultTimeRange;
|
||||
|
||||
return testAction(
|
||||
fetchMoreLogsPrepend,
|
||||
null,
|
||||
state,
|
||||
[
|
||||
{ type: types.REQUEST_LOGS_DATA_PREPEND },
|
||||
{ type: types.RECEIVE_LOGS_DATA_PREPEND_ERROR },
|
||||
],
|
||||
[],
|
||||
() => {
|
||||
expect(mock.history.get[0].url).toBe(mockLogsEndpoint);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tracking user interaction', () => {
|
||||
let commit;
|
||||
let dispatch;
|
||||
let state;
|
||||
let mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Tracking, 'event');
|
||||
commit = jest.fn();
|
||||
dispatch = jest.fn();
|
||||
state = logsPageState();
|
||||
state.environments.options = mockEnvironments;
|
||||
state.environments.current = mockEnvName;
|
||||
|
||||
mock = new MockAdapter(axios);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe('Logs with data', () => {
|
||||
beforeEach(() => {
|
||||
mock.onGet(mockLogsEndpoint).reply(200, mockResponse);
|
||||
mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
|
||||
});
|
||||
|
||||
it('tracks fetched logs with data', () => {
|
||||
return fetchLogs({ state, commit, dispatch }, 'environment_selected').then(() => {
|
||||
expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'logs_view', {
|
||||
label: 'environment_selected',
|
||||
property: 'count',
|
||||
value: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logs without data', () => {
|
||||
beforeEach(() => {
|
||||
mock.onGet(mockLogsEndpoint).reply(200, {
|
||||
...mockResponse,
|
||||
logs: [],
|
||||
});
|
||||
mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
|
||||
});
|
||||
|
||||
it('does not track empty log responses', () => {
|
||||
return fetchLogs({ state, commit, dispatch }).then(() => {
|
||||
expect(Tracking.event).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { trace, showAdvancedFilters } from '~/logs/stores/getters';
|
||||
import logsPageState from '~/logs/stores/state';
|
||||
|
||||
import { mockLogsResult, mockTrace, mockEnvName, mockEnvironments } from '../mock_data';
|
||||
|
||||
describe('Logs Store getters', () => {
|
||||
let state;
|
||||
|
||||
beforeEach(() => {
|
||||
state = logsPageState();
|
||||
});
|
||||
|
||||
describe('trace', () => {
|
||||
describe('when state is initialized', () => {
|
||||
it('returns an empty string', () => {
|
||||
expect(trace(state)).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when state logs are empty', () => {
|
||||
beforeEach(() => {
|
||||
state.logs.lines = [];
|
||||
});
|
||||
|
||||
it('returns an empty string', () => {
|
||||
expect(trace(state)).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when state logs are set', () => {
|
||||
beforeEach(() => {
|
||||
state.logs.lines = mockLogsResult;
|
||||
});
|
||||
|
||||
it('returns an empty string', () => {
|
||||
expect(trace(state)).toEqual(mockTrace.join('\n'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('showAdvancedFilters', () => {
|
||||
describe('when no environments are set', () => {
|
||||
beforeEach(() => {
|
||||
state.environments.current = mockEnvName;
|
||||
state.environments.options = [];
|
||||
});
|
||||
|
||||
it('returns false', () => {
|
||||
expect(showAdvancedFilters(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the environment supports filters', () => {
|
||||
beforeEach(() => {
|
||||
state.environments.current = mockEnvName;
|
||||
state.environments.options = mockEnvironments;
|
||||
});
|
||||
|
||||
it('returns true', () => {
|
||||
expect(showAdvancedFilters(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the environment does not support filters', () => {
|
||||
beforeEach(() => {
|
||||
state.environments.options = mockEnvironments;
|
||||
state.environments.current = mockEnvironments[1].name;
|
||||
});
|
||||
|
||||
it('returns true', () => {
|
||||
expect(showAdvancedFilters(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,52 +1,40 @@
|
|||
import Vue, { nextTick } from 'vue';
|
||||
import PromptComponent from '~/notebook/cells/prompt.vue';
|
||||
|
||||
const Component = Vue.extend(PromptComponent);
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import Prompt from '~/notebook/cells/prompt.vue';
|
||||
|
||||
describe('Prompt component', () => {
|
||||
let vm;
|
||||
let wrapper;
|
||||
|
||||
const mountComponent = ({ type }) => shallowMount(Prompt, { propsData: { type, count: 1 } });
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('input', () => {
|
||||
beforeEach(() => {
|
||||
vm = new Component({
|
||||
propsData: {
|
||||
type: 'In',
|
||||
count: 1,
|
||||
},
|
||||
});
|
||||
vm.$mount();
|
||||
|
||||
return nextTick();
|
||||
wrapper = mountComponent({ type: 'In' });
|
||||
});
|
||||
|
||||
it('renders in label', () => {
|
||||
expect(vm.$el.textContent.trim()).toContain('In');
|
||||
expect(wrapper.text()).toContain('In');
|
||||
});
|
||||
|
||||
it('renders count', () => {
|
||||
expect(vm.$el.textContent.trim()).toContain('1');
|
||||
expect(wrapper.text()).toContain('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('output', () => {
|
||||
beforeEach(() => {
|
||||
vm = new Component({
|
||||
propsData: {
|
||||
type: 'Out',
|
||||
count: 1,
|
||||
},
|
||||
});
|
||||
vm.$mount();
|
||||
|
||||
return nextTick();
|
||||
wrapper = mountComponent({ type: 'Out' });
|
||||
});
|
||||
|
||||
it('renders in label', () => {
|
||||
expect(vm.$el.textContent.trim()).toContain('Out');
|
||||
expect(wrapper.text()).toContain('Out');
|
||||
});
|
||||
|
||||
it('renders count', () => {
|
||||
expect(vm.$el.textContent.trim()).toContain('1');
|
||||
expect(wrapper.text()).toContain('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,41 +1,30 @@
|
|||
import Vue from 'vue';
|
||||
import noteSignedOut from '~/notes/components/note_signed_out_widget.vue';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue';
|
||||
import createStore from '~/notes/stores';
|
||||
import { notesDataMock } from '../mock_data';
|
||||
|
||||
describe('note_signed_out_widget component', () => {
|
||||
let store;
|
||||
let vm;
|
||||
describe('NoteSignedOutWidget component', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
const Component = Vue.extend(noteSignedOut);
|
||||
store = createStore();
|
||||
const store = createStore();
|
||||
store.dispatch('setNotesData', notesDataMock);
|
||||
|
||||
vm = new Component({
|
||||
store,
|
||||
}).$mount();
|
||||
wrapper = shallowMount(NoteSignedOutWidget, { store });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('should render sign in link provided in the store', () => {
|
||||
expect(vm.$el.querySelector(`a[href="${notesDataMock.newSessionPath}"]`).textContent).toEqual(
|
||||
'sign in',
|
||||
);
|
||||
it('renders sign in link provided in the store', () => {
|
||||
expect(wrapper.find(`a[href="${notesDataMock.newSessionPath}"]`).text()).toBe('sign in');
|
||||
});
|
||||
|
||||
it('should render register link provided in the store', () => {
|
||||
expect(vm.$el.querySelector(`a[href="${notesDataMock.registerPath}"]`).textContent).toEqual(
|
||||
'register',
|
||||
);
|
||||
it('renders register link provided in the store', () => {
|
||||
expect(wrapper.find(`a[href="${notesDataMock.registerPath}"]`).text()).toBe('register');
|
||||
});
|
||||
|
||||
it('should render information text', () => {
|
||||
expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual(
|
||||
'Please register or sign in to reply',
|
||||
);
|
||||
it('renders information text', () => {
|
||||
expect(wrapper.text()).toContain('Please register or sign in to reply');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,48 +1,33 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { FIXTURES_PATH } from 'spec/test_constants';
|
||||
import PDFLab from '~/pdf/index.vue';
|
||||
|
||||
jest.mock('pdfjs-dist/webpack', () => {
|
||||
return { default: jest.requireActual('pdfjs-dist/build/pdf') };
|
||||
});
|
||||
describe('PDFLab component', () => {
|
||||
let wrapper;
|
||||
|
||||
const pdf = `${FIXTURES_PATH}/blob/pdf/test.pdf`;
|
||||
const mountComponent = ({ pdf }) => shallowMount(PDFLab, { propsData: { pdf } });
|
||||
|
||||
const Component = Vue.extend(PDFLab);
|
||||
|
||||
describe('PDF component', () => {
|
||||
let vm;
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('without PDF data', () => {
|
||||
beforeEach(() => {
|
||||
vm = new Component({
|
||||
propsData: {
|
||||
pdf: '',
|
||||
},
|
||||
});
|
||||
|
||||
vm.$mount();
|
||||
wrapper = mountComponent({ pdf: '' });
|
||||
});
|
||||
|
||||
it('does not render', () => {
|
||||
expect(vm.$el.tagName).toBeUndefined();
|
||||
expect(wrapper.isVisible()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with PDF data', () => {
|
||||
beforeEach(() => {
|
||||
vm = new Component({
|
||||
propsData: {
|
||||
pdf,
|
||||
},
|
||||
});
|
||||
|
||||
vm.$mount();
|
||||
wrapper = mountComponent({ pdf: `${FIXTURES_PATH}/blob/pdf/test.pdf` });
|
||||
});
|
||||
|
||||
it('renders pdf component', () => {
|
||||
expect(vm.$el.tagName).toBeDefined();
|
||||
it('renders', () => {
|
||||
expect(wrapper.isVisible()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import PageSizeSelector, { PAGE_SIZES } from '~/vue_shared/components/page_size_selector.vue';
|
||||
|
||||
describe('Page size selector component', () => {
|
||||
let wrapper;
|
||||
|
||||
const createWrapper = ({ pageSize = 20 } = {}) => {
|
||||
wrapper = shallowMount(PageSizeSelector, {
|
||||
propsData: { value: pageSize },
|
||||
});
|
||||
};
|
||||
|
||||
const findDropdown = () => wrapper.findComponent(GlDropdown);
|
||||
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it.each(PAGE_SIZES)('shows expected text in the dropdown button for page size %s', (pageSize) => {
|
||||
createWrapper({ pageSize });
|
||||
|
||||
expect(findDropdown().props('text')).toBe(`Show ${pageSize} items`);
|
||||
});
|
||||
|
||||
it('shows the expected dropdown items', () => {
|
||||
createWrapper();
|
||||
|
||||
PAGE_SIZES.forEach((pageSize, index) => {
|
||||
expect(findDropdownItems().at(index).text()).toBe(`Show ${pageSize} items`);
|
||||
});
|
||||
});
|
||||
|
||||
it('will emit the new page size when a dropdown item is clicked', () => {
|
||||
createWrapper();
|
||||
|
||||
findDropdownItems().wrappers.forEach((itemWrapper, index) => {
|
||||
itemWrapper.vm.$emit('click');
|
||||
|
||||
expect(wrapper.emitted('input')[index][0]).toBe(PAGE_SIZES[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vu
|
|||
import IssuableListRoot from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
|
||||
import IssuableTabs from '~/vue_shared/issuable/list/components/issuable_tabs.vue';
|
||||
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
|
||||
import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue';
|
||||
|
||||
import { mockIssuableListProps, mockIssuables } from '../mock_data';
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ describe('IssuableListRoot', () => {
|
|||
const findIssuableItem = () => wrapper.findComponent(IssuableItem);
|
||||
const findIssuableTabs = () => wrapper.findComponent(IssuableTabs);
|
||||
const findVueDraggable = () => wrapper.findComponent(VueDraggable);
|
||||
const findPageSizeSelector = () => wrapper.findComponent(PageSizeSelector);
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
|
|
@ -292,6 +294,7 @@ describe('IssuableListRoot', () => {
|
|||
});
|
||||
|
||||
expect(findGlKeysetPagination().exists()).toBe(false);
|
||||
expect(findPageSizeSelector().exists()).toBe(false);
|
||||
expect(findGlPagination().props()).toMatchObject({
|
||||
perPage: 20,
|
||||
value: 1,
|
||||
|
|
@ -483,4 +486,24 @@ describe('IssuableListRoot', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('page size selector', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({
|
||||
props: {
|
||||
showPageSizeChangeControls: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('has the page size change component', async () => {
|
||||
expect(findPageSizeSelector().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('emits "page-size-change" event when its input is changed', () => {
|
||||
const pageSize = 123;
|
||||
findPageSizeSelector().vm.$emit('input', pageSize);
|
||||
expect(wrapper.emitted('page-size-change')).toEqual([[pageSize]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -741,7 +741,7 @@ RSpec.describe SearchHelper do
|
|||
let(:for_group) { true }
|
||||
|
||||
it 'adds the :group and :group_metadata correctly to hash' do
|
||||
expect(header_search_context[:group]).to eq({ id: group.id, name: group.name })
|
||||
expect(header_search_context[:group]).to eq({ id: group.id, name: group.name, full_name: group.full_name })
|
||||
expect(header_search_context[:group_metadata]).to eq(group_metadata)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ RSpec.describe BulkImports::Pipeline::Runner do
|
|||
Class.new do
|
||||
def initialize(options = {}); end
|
||||
|
||||
def transform(context); end
|
||||
def transform(context, data); end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ RSpec.describe BulkImports::Pipeline::Runner do
|
|||
Class.new do
|
||||
def initialize(options = {}); end
|
||||
|
||||
def load(context); end
|
||||
def load(context, data); end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -44,11 +44,73 @@ RSpec.describe BulkImports::Pipeline::Runner do
|
|||
end
|
||||
|
||||
let_it_be_with_reload(:entity) { create(:bulk_import_entity) }
|
||||
let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) }
|
||||
let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker, extra: :data) }
|
||||
|
||||
let(:tracker) { create(:bulk_import_tracker, entity: entity) }
|
||||
let(:context) { BulkImports::Pipeline::Context.new(tracker, extra: :data) }
|
||||
|
||||
subject { BulkImports::MyPipeline.new(context) }
|
||||
|
||||
shared_examples 'failed pipeline' do |exception_class, exception_message|
|
||||
it 'logs import failure' do
|
||||
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
|
||||
expect(logger).to receive(:error)
|
||||
.with(
|
||||
log_params(
|
||||
context,
|
||||
pipeline_step: :extractor,
|
||||
pipeline_class: 'BulkImports::MyPipeline',
|
||||
exception_class: exception_class,
|
||||
exception_message: exception_message
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
expect { subject.run }
|
||||
.to change(entity.failures, :count).by(1)
|
||||
|
||||
failure = entity.failures.first
|
||||
|
||||
expect(failure).to be_present
|
||||
expect(failure.pipeline_class).to eq('BulkImports::MyPipeline')
|
||||
expect(failure.pipeline_step).to eq('extractor')
|
||||
expect(failure.exception_class).to eq(exception_class)
|
||||
expect(failure.exception_message).to eq(exception_message)
|
||||
end
|
||||
|
||||
context 'when pipeline is marked to abort on failure' do
|
||||
before do
|
||||
BulkImports::MyPipeline.abort_on_failure!
|
||||
end
|
||||
|
||||
it 'logs a warn message and marks entity and tracker as failed' do
|
||||
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
|
||||
expect(logger).to receive(:warn)
|
||||
.with(
|
||||
log_params(
|
||||
context,
|
||||
message: 'Aborting entity migration due to pipeline failure',
|
||||
pipeline_class: 'BulkImports::MyPipeline'
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
subject.run
|
||||
|
||||
expect(entity.failed?).to eq(true)
|
||||
expect(tracker.failed?).to eq(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when pipeline is not marked to abort on failure' do
|
||||
it 'does not mark entity as failed' do
|
||||
subject.run
|
||||
|
||||
expect(tracker.failed?).to eq(true)
|
||||
expect(entity.failed?).to eq(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'pipeline runner' do
|
||||
context 'when entity is not marked as failed' do
|
||||
it 'runs pipeline extractor, transformer, loader' do
|
||||
|
|
@ -145,70 +207,65 @@ RSpec.describe BulkImports::Pipeline::Runner do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when exception is raised' do
|
||||
context 'when the exception BulkImports::NetworkError is raised' do
|
||||
before do
|
||||
allow_next_instance_of(BulkImports::Extractor) do |extractor|
|
||||
allow(extractor).to receive(:extract).with(context).and_raise(
|
||||
BulkImports::NetworkError.new(
|
||||
'Net::ReadTimeout',
|
||||
response: instance_double(HTTParty::Response, code: reponse_status_code, headers: {})
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when exception is retriable' do
|
||||
let(:reponse_status_code) { 429 }
|
||||
|
||||
it 'raises the exception BulkImports::RetryPipelineError' do
|
||||
expect { subject.run }.to raise_error(BulkImports::RetryPipelineError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when exception is not retriable' do
|
||||
let(:reponse_status_code) { 503 }
|
||||
|
||||
it_behaves_like 'failed pipeline', 'BulkImports::NetworkError', 'Net::ReadTimeout'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a retriable BulkImports::NetworkError exception is raised while extracting the next page' do
|
||||
before do
|
||||
call_count = 0
|
||||
allow_next_instance_of(BulkImports::Extractor) do |extractor|
|
||||
allow(extractor).to receive(:extract).with(context).twice do
|
||||
if call_count.zero?
|
||||
call_count += 1
|
||||
extracted_data(has_next_page: true)
|
||||
else
|
||||
raise(
|
||||
BulkImports::NetworkError.new(
|
||||
response: instance_double(HTTParty::Response, code: 429, headers: {})
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises the exception BulkImports::RetryPipelineError' do
|
||||
expect { subject.run }.to raise_error(BulkImports::RetryPipelineError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the exception StandardError is raised' do
|
||||
before do
|
||||
allow_next_instance_of(BulkImports::Extractor) do |extractor|
|
||||
allow(extractor).to receive(:extract).with(context).and_raise(StandardError, 'Error!')
|
||||
end
|
||||
end
|
||||
|
||||
it 'logs import failure' do
|
||||
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
|
||||
expect(logger).to receive(:error)
|
||||
.with(
|
||||
log_params(
|
||||
context,
|
||||
pipeline_step: :extractor,
|
||||
pipeline_class: 'BulkImports::MyPipeline',
|
||||
exception_class: 'StandardError',
|
||||
exception_message: 'Error!'
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
expect { subject.run }
|
||||
.to change(entity.failures, :count).by(1)
|
||||
|
||||
failure = entity.failures.first
|
||||
|
||||
expect(failure).to be_present
|
||||
expect(failure.pipeline_class).to eq('BulkImports::MyPipeline')
|
||||
expect(failure.pipeline_step).to eq('extractor')
|
||||
expect(failure.exception_class).to eq('StandardError')
|
||||
expect(failure.exception_message).to eq('Error!')
|
||||
end
|
||||
|
||||
context 'when pipeline is marked to abort on failure' do
|
||||
before do
|
||||
BulkImports::MyPipeline.abort_on_failure!
|
||||
end
|
||||
|
||||
it 'logs a warn message and marks entity as failed' do
|
||||
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
|
||||
expect(logger).to receive(:warn)
|
||||
.with(
|
||||
log_params(
|
||||
context,
|
||||
message: 'Pipeline failed',
|
||||
pipeline_class: 'BulkImports::MyPipeline'
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
subject.run
|
||||
|
||||
expect(entity.status_name).to eq(:failed)
|
||||
expect(tracker.status_name).to eq(:failed)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when pipeline is not marked to abort on failure' do
|
||||
it 'does not mark entity as failed' do
|
||||
subject.run
|
||||
|
||||
expect(entity.failed?).to eq(false)
|
||||
end
|
||||
end
|
||||
it_behaves_like 'failed pipeline', 'StandardError', 'Error!'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe BulkImports::RetryPipelineError do
|
||||
describe '#retry_delay' do
|
||||
it 'returns retry_delay' do
|
||||
exception = described_class.new('Error!', 60)
|
||||
|
||||
expect(exception.retry_delay).to eq(60)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -17,10 +17,10 @@ RSpec.describe Gitlab::ErrorTracking::ErrorRepository::OpenApiStrategy do
|
|||
|
||||
shared_examples 'exception logging' do
|
||||
it 'logs error' do
|
||||
expect(Gitlab::AppLogger).to receive(:error).with(
|
||||
expect(Gitlab::AppLogger).to receive(:error).with({
|
||||
'open_api.http_code' => api_exception.code,
|
||||
'open_api.response_body' => api_exception.response_body.truncate(100)
|
||||
)
|
||||
})
|
||||
|
||||
subject
|
||||
end
|
||||
|
|
@ -66,11 +66,11 @@ RSpec.describe Gitlab::ErrorTracking::ErrorRepository::OpenApiStrategy do
|
|||
.and_return(error)
|
||||
|
||||
allow(open_api).to receive(:list_events)
|
||||
.with(project.id, error.fingerprint, sort: 'occurred_at_asc', limit: 1)
|
||||
.with(project.id, error.fingerprint, { sort: 'occurred_at_asc', limit: 1 })
|
||||
.and_return(list_events_asc)
|
||||
|
||||
allow(open_api).to receive(:list_events)
|
||||
.with(project.id, error.fingerprint, sort: 'occurred_at_desc', limit: 1)
|
||||
.with(project.id, error.fingerprint, { sort: 'occurred_at_desc', limit: 1 })
|
||||
.and_return(list_events_desc)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -82,11 +82,43 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl
|
|||
end
|
||||
|
||||
describe '.track_approve_mr_action' do
|
||||
subject { described_class.track_approve_mr_action(user: user) }
|
||||
include ProjectForksHelper
|
||||
|
||||
let(:merge_request) { create(:merge_request, target_project: target_project, source_project: source_project) }
|
||||
let(:source_project) { fork_project(target_project) }
|
||||
let(:target_project) { create(:project) }
|
||||
|
||||
subject { described_class.track_approve_mr_action(user: user, merge_request: merge_request) }
|
||||
|
||||
it_behaves_like 'a tracked merge request unique event' do
|
||||
let(:action) { described_class::MR_APPROVE_ACTION }
|
||||
end
|
||||
|
||||
it 'records correct payload with Snowplow event', :snowplow do
|
||||
stub_feature_flags(route_hll_to_snowplow_phase2: true)
|
||||
|
||||
subject
|
||||
|
||||
expect_snowplow_event(
|
||||
category: 'merge_requests',
|
||||
action: 'i_code_review_user_approve_mr',
|
||||
namespace: target_project.namespace,
|
||||
user: user,
|
||||
project: target_project
|
||||
)
|
||||
end
|
||||
|
||||
context 'when FF is disabled' do
|
||||
before do
|
||||
stub_feature_flags(route_hll_to_snowplow_phase2: false)
|
||||
end
|
||||
|
||||
it 'doesnt emit snowplow events', :snowplow do
|
||||
subject
|
||||
|
||||
expect_no_snowplow_event
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.track_unapprove_mr_action' do
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Issues::RelatedBranchesService do
|
||||
RSpec.describe Issues::RelatedBranchesService, :clean_gitlab_redis_cache do
|
||||
let_it_be(:developer) { create(:user) }
|
||||
let_it_be(:issue) { create(:issue) }
|
||||
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ RSpec.describe MergeRequests::ApprovalService do
|
|||
|
||||
it 'tracks merge request approve action' do
|
||||
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
|
||||
.to receive(:track_approve_mr_action).with(user: user)
|
||||
.to receive(:track_approve_mr_action).with(user: user, merge_request: merge_request)
|
||||
|
||||
service.execute(merge_request)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -25,8 +25,12 @@ RSpec.describe Projects::DestroyService, :aggregate_failures, :event_store_publi
|
|||
expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey
|
||||
end
|
||||
|
||||
it 'publishes a ProjectDeleted event with project id and namespace id' do
|
||||
expected_data = { project_id: project.id, namespace_id: project.namespace_id }
|
||||
it 'publishes a ProjectDeletedEvent' do
|
||||
expected_data = {
|
||||
project_id: project.id,
|
||||
namespace_id: project.namespace_id,
|
||||
root_namespace_id: project.root_namespace.id
|
||||
}
|
||||
|
||||
expect { destroy_project(project, user, {}) }.to publish_event(Projects::ProjectDeletedEvent).with(expected_data)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ RSpec.describe BulkImports::PipelineWorker do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when network error is raised' do
|
||||
context 'when retry pipeline error is raised' do
|
||||
let(:pipeline_tracker) do
|
||||
create(
|
||||
:bulk_import_tracker,
|
||||
|
|
@ -200,7 +200,7 @@ RSpec.describe BulkImports::PipelineWorker do
|
|||
end
|
||||
|
||||
let(:exception) do
|
||||
BulkImports::NetworkError.new(response: instance_double(HTTParty::Response, code: 429, headers: {}))
|
||||
BulkImports::RetryPipelineError.new('Error!', 60)
|
||||
end
|
||||
|
||||
before do
|
||||
|
|
@ -213,54 +213,36 @@ RSpec.describe BulkImports::PipelineWorker do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when error is retriable' do
|
||||
it 'reenqueues the worker' do
|
||||
expect_any_instance_of(BulkImports::Tracker) do |tracker|
|
||||
expect(tracker).to receive(:retry).and_call_original
|
||||
end
|
||||
it 'reenqueues the worker' do
|
||||
expect_any_instance_of(BulkImports::Tracker) do |tracker|
|
||||
expect(tracker).to receive(:retry).and_call_original
|
||||
end
|
||||
|
||||
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
|
||||
expect(logger)
|
||||
.to receive(:info)
|
||||
.with(
|
||||
hash_including(
|
||||
'pipeline_name' => 'FakePipeline',
|
||||
'entity_id' => entity.id
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
expect(described_class)
|
||||
.to receive(:perform_in)
|
||||
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
|
||||
expect(logger)
|
||||
.to receive(:info)
|
||||
.with(
|
||||
60.seconds,
|
||||
pipeline_tracker.id,
|
||||
pipeline_tracker.stage,
|
||||
pipeline_tracker.entity.id
|
||||
hash_including(
|
||||
'pipeline_name' => 'FakePipeline',
|
||||
'entity_id' => entity.id
|
||||
)
|
||||
)
|
||||
|
||||
subject.perform(pipeline_tracker.id, pipeline_tracker.stage, entity.id)
|
||||
|
||||
pipeline_tracker.reload
|
||||
|
||||
expect(pipeline_tracker.enqueued?).to be_truthy
|
||||
end
|
||||
|
||||
context 'when error is not retriable' do
|
||||
let(:exception) do
|
||||
BulkImports::NetworkError.new(response: instance_double(HTTParty::Response, code: 503, headers: {}))
|
||||
end
|
||||
expect(described_class)
|
||||
.to receive(:perform_in)
|
||||
.with(
|
||||
60.seconds,
|
||||
pipeline_tracker.id,
|
||||
pipeline_tracker.stage,
|
||||
pipeline_tracker.entity.id
|
||||
)
|
||||
|
||||
it 'marks tracker as failed and logs the error' do
|
||||
expect(described_class).not_to receive(:perform_in)
|
||||
subject.perform(pipeline_tracker.id, pipeline_tracker.stage, entity.id)
|
||||
|
||||
subject.perform(pipeline_tracker.id, pipeline_tracker.stage, entity.id)
|
||||
pipeline_tracker.reload
|
||||
|
||||
pipeline_tracker.reload
|
||||
|
||||
expect(pipeline_tracker.failed?).to eq(true)
|
||||
end
|
||||
end
|
||||
expect(pipeline_tracker.enqueued?).to be_truthy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue