Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-10-26 12:08:44 +00:00
parent c60d68bbac
commit 6e320396b2
64 changed files with 1391 additions and 433 deletions

View File

@ -124,7 +124,6 @@ export default {
return {
treeWidth,
diffFilesLength: 0,
collapsedWarningDismissed: false,
};
},
computed: {
@ -153,7 +152,7 @@ export default {
'canMerge',
'hasConflicts',
]),
...mapGetters('diffs', ['hasCollapsedFile', 'isParallelView', 'currentDiffIndex']),
...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
diffs() {
if (!this.viewDiffsFileByFile) {
@ -206,11 +205,7 @@ export default {
visible = this.$options.alerts.ALERT_OVERFLOW_HIDDEN;
} else if (this.isDiffHead && this.hasConflicts) {
visible = this.$options.alerts.ALERT_MERGE_CONFLICT;
} else if (
this.hasCollapsedFile &&
!this.collapsedWarningDismissed &&
!this.viewDiffsFileByFile
) {
} else if (this.whichCollapsedTypes.automatic && !this.viewDiffsFileByFile) {
visible = this.$options.alerts.ALERT_COLLAPSED_FILES;
}
@ -429,9 +424,6 @@ export default {
this.toggleShowTreeList(false);
}
},
dismissCollapsedWarning() {
this.collapsedWarningDismissed = true;
},
},
minTreeWidth: MIN_TREE_WIDTH,
maxTreeWidth: MAX_TREE_WIDTH,
@ -464,7 +456,6 @@ export default {
<collapsed-files-warning
v-if="visibleWarning == $options.alerts.ALERT_COLLAPSED_FILES"
:limited="isLimitedContainer"
@dismiss="dismissCollapsedWarning"
/>
<div

View File

@ -38,7 +38,7 @@ export default {
},
computed: {
...mapGetters('diffs', [
'hasCollapsedFile',
'whichCollapsedTypes',
'diffCompareDropdownTargetVersions',
'diffCompareDropdownSourceVersions',
]),
@ -129,7 +129,7 @@ export default {
{{ __('Show latest version') }}
</gl-button>
<gl-button
v-show="hasCollapsedFile"
v-show="whichCollapsedTypes.any"
variant="default"
class="gl-mr-3"
@click="expandAllFiles"

View File

@ -10,6 +10,8 @@ import eventHub from '../../notes/event_hub';
import DiffFileHeader from './diff_file_header.vue';
import DiffContent from './diff_content.vue';
import { diffViewerErrors } from '~/ide/constants';
import { collapsedType, isCollapsed } from '../diff_file';
import { DIFF_FILE_AUTOMATIC_COLLAPSE, DIFF_FILE_MANUAL_COLLAPSE } from '../constants';
export default {
components: {
@ -44,7 +46,7 @@ export default {
return {
isLoadingCollapsedDiff: false,
forkMessageVisible: false,
isCollapsed: this.file.viewer.automaticallyCollapsed || false,
isCollapsed: isCollapsed(this.file),
};
},
computed: {
@ -71,7 +73,7 @@ export default {
return this.file.viewer.error === diffViewerErrors.too_large;
},
errorMessage() {
return this.file.viewer.error_message;
return !this.manuallyCollapsed ? this.file.viewer.error_message : '';
},
forkMessage() {
return sprintf(
@ -85,57 +87,94 @@ export default {
false,
);
},
},
watch: {
isCollapsed: function fileCollapsedWatch(newVal, oldVal) {
if (!newVal && oldVal && !this.hasDiff) {
this.handleLoadCollapsedDiff();
hasBodyClasses() {
const domParts = {
header: 'gl-rounded-base!',
contentByHash: '',
content: '',
};
if (this.showBody) {
domParts.header = 'gl-rounded-bottom-left-none gl-rounded-bottom-right-none';
domParts.contentByHash =
'gl-rounded-none gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-border-1 gl-border-t-0! gl-border-solid gl-border-gray-100';
domParts.content = 'gl-rounded-bottom-left-base gl-rounded-bottom-right-base';
}
this.setFileCollapsed({ filePath: this.file.file_path, collapsed: newVal });
return domParts;
},
automaticallyCollapsed() {
return collapsedType(this.file) === DIFF_FILE_AUTOMATIC_COLLAPSE;
},
manuallyCollapsed() {
return collapsedType(this.file) === DIFF_FILE_MANUAL_COLLAPSE;
},
showBody() {
return !this.isCollapsed || this.automaticallyCollapsed;
},
showWarning() {
return this.isCollapsed && (this.automaticallyCollapsed && !this.viewDiffsFileByFile);
},
showContent() {
return !this.isCollapsed && !this.isFileTooLarge;
},
},
watch: {
'file.file_hash': {
handler: function watchFileHash() {
if (this.viewDiffsFileByFile && this.file.viewer.automaticallyCollapsed) {
this.isCollapsed = false;
this.handleLoadCollapsedDiff();
} else {
this.isCollapsed = this.file.viewer.automaticallyCollapsed || false;
handler: function hashChangeWatch(newHash, oldHash) {
this.isCollapsed = isCollapsed(this.file);
if (newHash && oldHash && !this.hasDiff) {
this.requestDiff();
}
},
immediate: true,
},
'file.viewer.automaticallyCollapsed': function setIsCollapsed(newVal) {
if (!this.viewDiffsFileByFile) {
this.isCollapsed = newVal;
}
'file.viewer.automaticallyCollapsed': {
handler: function autoChangeWatch(automaticValue) {
if (collapsedType(this.file) !== DIFF_FILE_MANUAL_COLLAPSE) {
this.isCollapsed = this.viewDiffsFileByFile ? false : automaticValue;
}
},
immediate: true,
},
'file.viewer.manuallyCollapsed': {
handler: function manualChangeWatch(manualValue) {
if (manualValue !== null) {
this.isCollapsed = manualValue;
}
},
immediate: true,
},
},
created() {
eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff);
eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.requestDiff);
},
methods: {
...mapActions('diffs', [
'loadCollapsedDiff',
'assignDiscussionsToDiff',
'setRenderIt',
'setFileCollapsed',
'setFileCollapsedByUser',
]),
handleToggle() {
if (!this.hasDiff) {
this.handleLoadCollapsedDiff();
} else {
this.isCollapsed = !this.isCollapsed;
this.setRenderIt(this.file);
const currentCollapsedFlag = this.isCollapsed;
this.setFileCollapsedByUser({
filePath: this.file.file_path,
collapsed: !currentCollapsedFlag,
});
if (!this.hasDiff && currentCollapsedFlag) {
this.requestDiff();
}
},
handleLoadCollapsedDiff() {
requestDiff() {
this.isLoadingCollapsedDiff = true;
this.loadCollapsedDiff(this.file)
.then(() => {
this.isLoadingCollapsedDiff = false;
this.isCollapsed = false;
this.setRenderIt(this.file);
})
.then(() => {
@ -167,9 +206,10 @@ export default {
:class="{
'is-active': currentDiffFileId === file.file_hash,
'comments-disabled': Boolean(file.brokenSymlink),
'has-body': showBody,
}"
:data-path="file.new_path"
class="diff-file file-holder"
class="diff-file file-holder gl-border-none"
>
<diff-file-header
:can-current-user-fork="canCurrentUserFork"
@ -178,7 +218,8 @@ export default {
:expanded="!isCollapsed"
:add-merge-request-buttons="true"
:view-diffs-file-by-file="viewDiffsFileByFile"
class="js-file-title file-title"
class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100"
:class="hasBodyClasses.header"
@toggleFile="handleToggle"
@showForkMessage="showForkMessage"
/>
@ -198,21 +239,35 @@ export default {
{{ __('Cancel') }}
</button>
</div>
<gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" />
<template v-else>
<div :id="`diff-content-${file.file_hash}`">
<div v-if="errorMessage" class="diff-viewer">
<div
:id="`diff-content-${file.file_hash}`"
:class="hasBodyClasses.contentByHash"
data-testid="content-area"
>
<gl-loading-icon
v-if="showLoadingIcon"
class="diff-content loading gl-my-0 gl-pt-3"
data-testid="loader-icon"
/>
<div v-else-if="errorMessage" class="diff-viewer">
<div v-safe-html="errorMessage" class="nothing-here-block"></div>
</div>
<template v-else>
<div v-show="isCollapsed" class="nothing-here-block diff-collapsed">
<div v-show="showWarning" class="nothing-here-block diff-collapsed">
{{ __('This diff is collapsed.') }}
<a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{
__('Click to expand it.')
}}</a>
<a
class="click-to-expand"
data-testid="toggle-link"
href="#"
@click.prevent="handleToggle"
>
{{ __('Click to expand it.') }}
</a>
</div>
<diff-content
v-show="!isCollapsed && !isFileTooLarge"
v-show="showContent"
:class="hasBodyClasses.content"
:diff-file="file"
:help-page-path="helpPagePath"
/>

View File

@ -18,6 +18,7 @@ import { __, s__, sprintf } from '~/locale';
import { diffViewerModes } from '~/ide/constants';
import DiffStats from './diff_stats.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
import { isCollapsed } from '../diff_file';
import { DIFF_FILE_HEADER } from '../i18n';
export default {
@ -125,6 +126,9 @@ export default {
isUsingLfs() {
return this.diffFile.stored_externally && this.diffFile.external_storage === 'lfs';
},
isCollapsed() {
return isCollapsed(this.diffFile, { fileByFile: this.viewDiffsFileByFile });
},
collapseIcon() {
return this.expanded ? 'chevron-down' : 'chevron-right';
},
@ -334,7 +338,7 @@ export default {
</gl-dropdown-item>
</template>
<template v-if="!diffFile.viewer.automaticallyCollapsed">
<template v-if="!isCollapsed">
<gl-dropdown-divider
v-if="!diffFile.is_fully_expanded || diffHasDiscussions(diffFile)"
/>

View File

@ -73,6 +73,10 @@ export const ALERT_OVERFLOW_HIDDEN = 'overflow';
export const ALERT_MERGE_CONFLICT = 'merge-conflict';
export const ALERT_COLLAPSED_FILES = 'collapsed';
// Diff File collapse types
export const DIFF_FILE_AUTOMATIC_COLLAPSE = 'automatic';
export const DIFF_FILE_MANUAL_COLLAPSE = 'manual';
// State machine states
export const STATE_IDLING = 'idle';
export const STATE_LOADING = 'loading';

View File

@ -1,4 +1,9 @@
import { DIFF_FILE_SYMLINK_MODE, DIFF_FILE_DELETED_MODE } from './constants';
import {
DIFF_FILE_SYMLINK_MODE,
DIFF_FILE_DELETED_MODE,
DIFF_FILE_MANUAL_COLLAPSE,
DIFF_FILE_AUTOMATIC_COLLAPSE,
} from './constants';
function fileSymlinkInformation(file, fileList) {
const duplicates = fileList.filter(iteratedFile => iteratedFile.file_hash === file.file_hash);
@ -23,6 +28,7 @@ function collapsed(file) {
return {
automaticallyCollapsed: viewer.automaticallyCollapsed || viewer.collapsed || false,
manuallyCollapsed: null,
};
}
@ -37,3 +43,19 @@ export function prepareRawDiffFile({ file, allFiles }) {
return file;
}
export function collapsedType(file) {
const isManual = typeof file.viewer?.manuallyCollapsed === 'boolean';
return isManual ? DIFF_FILE_MANUAL_COLLAPSE : DIFF_FILE_AUTOMATIC_COLLAPSE;
}
export function isCollapsed(file) {
const type = collapsedType(file);
const collapsedStates = {
[DIFF_FILE_AUTOMATIC_COLLAPSE]: file.viewer?.automaticallyCollapsed || false,
[DIFF_FILE_MANUAL_COLLAPSE]: file.viewer?.manuallyCollapsed,
};
return collapsedStates[type];
}

View File

@ -40,8 +40,11 @@ import {
DIFF_WHITESPACE_COOKIE_NAME,
SHOW_WHITESPACE,
NO_SHOW_WHITESPACE,
DIFF_FILE_MANUAL_COLLAPSE,
DIFF_FILE_AUTOMATIC_COLLAPSE,
} from '../constants';
import { diffViewerModes } from '~/ide/constants';
import { isCollapsed } from '../diff_file';
export const setBaseConfig = ({ commit }, options) => {
const {
@ -239,6 +242,13 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi
if (file.viewer.automaticallyCollapsed) {
eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
scrollToElement(document.getElementById(file.file_hash));
} else if (file.viewer.manuallyCollapsed) {
commit(types.SET_FILE_COLLAPSED, {
filePath: file.file_path,
collapsed: false,
trigger: DIFF_FILE_AUTOMATIC_COLLAPSE,
});
eventHub.$emit('scrollToDiscussion');
} else {
eventHub.$emit('scrollToDiscussion');
}
@ -252,8 +262,7 @@ export const startRenderDiffsQueue = ({ state, commit }) => {
const nextFile = state.diffFiles.find(
file =>
!file.renderIt &&
(file.viewer &&
(!file.viewer.automaticallyCollapsed || file.viewer.name !== diffViewerModes.text)),
(file.viewer && (!isCollapsed(file) || file.viewer.name !== diffViewerModes.text)),
);
if (nextFile) {
@ -641,8 +650,9 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d
});
}
export const setFileCollapsed = ({ commit }, { filePath, collapsed }) =>
commit(types.SET_FILE_COLLAPSED, { filePath, collapsed });
export const setFileCollapsedByUser = ({ commit }, { filePath, collapsed }) => {
commit(types.SET_FILE_COLLAPSED, { filePath, collapsed, trigger: DIFF_FILE_MANUAL_COLLAPSE });
};
export const setSuggestPopoverDismissed = ({ commit, state }) =>
axios

View File

@ -8,8 +8,16 @@ export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW
export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE;
export const hasCollapsedFile = state =>
state.diffFiles.some(file => file.viewer && file.viewer.automaticallyCollapsed);
export const whichCollapsedTypes = state => {
const automatic = state.diffFiles.some(file => file.viewer?.automaticallyCollapsed);
const manual = state.diffFiles.some(file => file.viewer?.manuallyCollapsed);
return {
any: automatic || manual,
automatic,
manual,
};
};
export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null);

View File

@ -1,6 +1,10 @@
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { INLINE_DIFF_VIEW_TYPE } from '../constants';
import {
DIFF_FILE_MANUAL_COLLAPSE,
DIFF_FILE_AUTOMATIC_COLLAPSE,
INLINE_DIFF_VIEW_TYPE,
} from '../constants';
import {
findDiffFile,
addLineReferences,
@ -16,6 +20,12 @@ function updateDiffFilesInState(state, files) {
return Object.assign(state, { diffFiles: files });
}
function renderFile(file) {
Object.assign(file, {
renderIt: true,
});
}
export default {
[types.SET_BASE_CONFIG](state, options) {
const {
@ -81,9 +91,7 @@ export default {
},
[types.RENDER_FILE](state, file) {
Object.assign(file, {
renderIt: true,
});
renderFile(file);
},
[types.SET_MERGE_REQUEST_DIFFS](state, mergeRequestDiffs) {
@ -173,6 +181,7 @@ export default {
Object.assign(file, {
viewer: Object.assign(file.viewer, {
automaticallyCollapsed: false,
manuallyCollapsed: false,
}),
});
});
@ -351,11 +360,24 @@ export default {
file.isShowingFullFile = true;
file.isLoadingFullFile = false;
},
[types.SET_FILE_COLLAPSED](state, { filePath, collapsed }) {
[types.SET_FILE_COLLAPSED](
state,
{ filePath, collapsed, trigger = DIFF_FILE_AUTOMATIC_COLLAPSE },
) {
const file = state.diffFiles.find(f => f.file_path === filePath);
if (file && file.viewer) {
file.viewer.automaticallyCollapsed = collapsed;
if (trigger === DIFF_FILE_MANUAL_COLLAPSE) {
file.viewer.automaticallyCollapsed = false;
file.viewer.manuallyCollapsed = collapsed;
} else if (trigger === DIFF_FILE_AUTOMATIC_COLLAPSE) {
file.viewer.automaticallyCollapsed = collapsed;
file.viewer.manuallyCollapsed = null;
}
}
if (file && !collapsed) {
renderFile(file);
}
},
[types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) {

View File

@ -1,10 +1,9 @@
<script>
import { GlIcon } from '@gitlab/ui';
import tooltip from '~/vue_shared/directives/tooltip';
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
directives: {
tooltip,
GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@ -45,7 +44,7 @@ export default {
<template>
<button
v-tooltip
v-gl-tooltip
:aria-label="label"
:title="tooltipTitle"
type="button"

View File

@ -5,6 +5,7 @@ import { ApolloLink } from 'apollo-link';
import { BatchHttpLink } from 'apollo-link-batch-http';
import csrf from '~/lib/utils/csrf';
import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
export const fetchPolicies = {
CACHE_FIRST: 'cache-first',
@ -62,7 +63,7 @@ export default (resolvers = {}, config = {}) => {
return new ApolloClient({
typeDefs: config.typeDefs,
link: ApolloLink.from([performanceBarLink, uploadsLink]),
link: ApolloLink.from([performanceBarLink, new StartupJSLink(), uploadsLink]),
cache: new InMemoryCache({
...config.cacheConfig,
freezeResults: config.assumeImmutableResults,

View File

@ -0,0 +1,106 @@
import { ApolloLink, Observable } from 'apollo-link';
import { parse } from 'graphql';
import { isEqual, pickBy } from 'lodash';
/**
* Remove undefined values from object
* @param obj
* @returns {Dictionary<unknown>}
*/
const pickDefinedValues = obj => pickBy(obj, x => x !== undefined);
/**
* Compares two set of variables, order independent
*
* Ignores undefined values (in the top level) and supports arrays etc.
*/
const variablesMatch = (var1 = {}, var2 = {}) => {
return isEqual(pickDefinedValues(var1), pickDefinedValues(var2));
};
export class StartupJSLink extends ApolloLink {
constructor() {
super();
this.startupCalls = new Map();
this.parseStartupCalls(window.gl?.startup_graphql_calls || []);
}
// Extract operationNames from the queries and ensure that we can
// match operationName => element from result array
parseStartupCalls(calls) {
calls.forEach(call => {
const { query, variables, fetchCall } = call;
const operationName = parse(query)?.definitions?.find(x => x.kind === 'OperationDefinition')
?.name?.value;
if (operationName) {
this.startupCalls.set(operationName, {
variables,
fetchCall,
});
}
});
}
static noopRequest = (operation, forward) => forward(operation);
disable() {
this.request = StartupJSLink.noopRequest;
this.startupCalls = null;
}
request(operation, forward) {
// Disable StartupJSLink in case all calls are done or none are set up
if (this.startupCalls && this.startupCalls.size === 0) {
this.disable();
return forward(operation);
}
const { operationName } = operation;
// Skip startup call if the operationName doesn't match
if (!this.startupCalls.has(operationName)) {
return forward(operation);
}
const { variables: startupVariables, fetchCall } = this.startupCalls.get(operationName);
this.startupCalls.delete(operationName);
// Skip startup call if the variables values do not match
if (!variablesMatch(startupVariables, operation.variables)) {
return forward(operation);
}
return new Observable(observer => {
fetchCall
.then(response => {
// Handle HTTP errors
if (!response.ok) {
throw new Error('fetchCall failed');
}
operation.setContext({ response });
return response.json();
})
.then(result => {
if (result && (result.errors || !result.data)) {
throw new Error('Received GraphQL error');
}
// we have data and can send it to back up the link chain
observer.next(result);
observer.complete();
})
.catch(() => {
forward(operation).subscribe({
next: result => {
observer.next(result);
},
error: error => {
observer.error(error);
},
complete: observer.complete.bind(observer),
});
});
});
}
}

View File

@ -7,6 +7,7 @@ import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
import { getDiffMode } from '~/diffs/store/utils';
import { diffViewerModes } from '~/ide/constants';
import { isCollapsed } from '../../diffs/diff_file';
const FIRST_CHAR_REGEX = /^(\+|-| )/;
@ -46,6 +47,9 @@ export default {
this.discussion.truncated_diff_lines && this.discussion.truncated_diff_lines.length !== 0
);
},
isCollapsed() {
return isCollapsed(this.discussion.diff_file);
},
},
mounted() {
if (this.isTextFile && !this.hasTruncatedDiffLines) {
@ -76,7 +80,7 @@ export default {
:discussion-path="discussion.discussion_path"
:diff-file="discussion.diff_file"
:can-current-user-fork="false"
:expanded="!discussion.diff_file.viewer.automaticallyCollapsed"
:expanded="!isCollapsed"
/>
<div v-if="isTextFile" class="diff-content">
<table class="code js-syntax-highlight" :class="$options.userColorSchemeClass">

View File

@ -6,12 +6,12 @@ import {
GlDropdownItem,
GlIcon,
} from '@gitlab/ui';
import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import { __ } from '../../locale';
import getRefMixin from '../mixins/get_ref';
import projectShortPathQuery from '../queries/project_short_path.query.graphql';
import projectPathQuery from '../queries/project_path.query.graphql';
import permissionsQuery from '../queries/permissions.query.graphql';
const ROW_TYPES = {
header: 'header',

View File

@ -1,9 +1,9 @@
<script>
import filesQuery from 'shared_queries/repository/files.query.graphql';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __ } from '../../locale';
import FileTable from './table/index.vue';
import getRefMixin from '../mixins/get_ref';
import filesQuery from '../queries/files.query.graphql';
import projectPathQuery from '../queries/project_path.query.graphql';
import FilePreview from './preview/index.vue';
import { readmeFile } from '../utils/readme';

View File

@ -1,5 +1,4 @@
import Vue from 'vue';
import PathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql';
import { escapeFileUrl } from '../lib/utils/url_utility';
import createRouter from './router';
import App from './components/app.vue';
@ -19,10 +18,6 @@ export default function setupVueRepositoryList() {
const { dataset } = el;
const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset;
const router = createRouter(projectPath, escapedRef);
const pathRegex = /-\/tree\/[^/]+\/(.+$)/;
const matches = window.location.href.match(pathRegex);
const currentRoutePath = matches ? matches[1] : '';
apolloProvider.clients.defaultClient.cache.writeData({
data: {
@ -48,28 +43,7 @@ export default function setupVueRepositoryList() {
},
});
if (window.gl.startup_graphql_calls) {
const query = window.gl.startup_graphql_calls.find(
call => call.operationName === 'pathLastCommit',
);
query.fetchCall
.then(res => res.json())
.then(res => {
apolloProvider.clients.defaultClient.writeQuery({
query: PathLastCommitQuery,
data: res.data,
variables: {
projectPath,
ref,
path: currentRoutePath,
},
});
})
.catch(() => {})
.finally(() => initLastCommitApp());
} else {
initLastCommitApp();
}
initLastCommitApp();
router.afterEach(({ params: { path } }) => {
setTitle(path, ref, fullName);

View File

@ -1,4 +1,4 @@
import filesQuery from '../queries/files.query.graphql';
import filesQuery from 'shared_queries/repository/files.query.graphql';
import getRefMixin from './get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';

View File

@ -6,11 +6,18 @@
border-top: 1px solid $border-color;
}
&.has-body {
.file-title {
box-shadow: 0 -2px 0 0 var(--white);
}
}
table.code tr:last-of-type td:last-of-type {
@include gl-rounded-bottom-right-base();
}
.file-title,
.file-title-flex-parent {
border-top-left-radius: $border-radius-default;
border-top-right-radius: $border-radius-default;
box-shadow: 0 -2px 0 0 var(--white);
cursor: pointer;
.dropdown-menu {
@ -113,7 +120,6 @@
.diff-content {
background: $white;
color: $gl-text-color;
border-radius: 0 0 3px 3px;
.unfold {
cursor: pointer;
@ -457,8 +463,11 @@ table.code {
border-top: 0;
}
tr:nth-last-of-type(2).line_expansion > td {
border-bottom: 0;
tr:nth-last-of-type(2).line_expansion,
tr:last-of-type.line_expansion {
> td {
border-bottom: 0;
}
}
tr.line_holder td {

View File

@ -11,9 +11,19 @@
}
.diff-tree-list {
// This 11px value should match the additional value found in
// /assets/stylesheets/framework/diffs.scss
// for the $mr-file-header-top SCSS variable within the
// .file-title,
// .file-title-flex-parent {
// rule.
// If they don't match, the file tree and the diff files stick
// to the top at different heights, which is a bad-looking defect
$diff-file-header-top: 11px;
$top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + $diff-file-header-top;
position: -webkit-sticky;
position: sticky;
$top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 15px;
top: $top-pos;
max-height: calc(100vh - #{$top-pos});
z-index: 202;

View File

@ -59,10 +59,17 @@ module Repositories
params[:size].to_i
end
def uploaded_file
params[:file]
end
# rubocop: disable CodeReuse/ActiveRecord
def store_file!(oid, size)
object = LfsObject.find_by(oid: oid, size: size)
unless object&.file&.exists?
if object
replace_file!(object) unless object.file&.exists?
else
object = create_file!(oid, size)
end
@ -73,12 +80,19 @@ module Repositories
# rubocop: enable CodeReuse/ActiveRecord
def create_file!(oid, size)
uploaded_file = params[:file]
return unless uploaded_file.is_a?(UploadedFile)
LfsObject.create!(oid: oid, size: size, file: uploaded_file)
end
def replace_file!(lfs_object)
raise UploadedFile::InvalidPathError unless uploaded_file.is_a?(UploadedFile)
Gitlab::AppJsonLogger.info(message: "LFS file replaced because it did not exist", oid: oid, size: size)
lfs_object.file = uploaded_file
lfs_object.save!
end
def link_to_project!(object)
return unless object

View File

@ -38,6 +38,7 @@ class SearchController < ApplicationController
return if check_single_commit_result?
@search_term = params[:search]
@sort = params[:sort] || default_sort
@scope = search_service.scope
@show_snippets = search_service.show_snippets?
@ -81,6 +82,11 @@ class SearchController < ApplicationController
SCOPE_PRELOAD_METHOD[@scope.to_sym]
end
# overridden in EE
def default_sort
'created_desc'
end
def search_term_valid?
unless search_service.valid_query_length?
flash[:alert] = t('errors.messages.search_chars_too_long', count: SearchService::SEARCH_CHAR_LIMIT)

View File

@ -1,6 +1,13 @@
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
fragment PageInfo on PageInfo {
__typename
hasNextPage
hasPreviousPage
startCursor
endCursor
}
fragment TreeEntry on Entry {
__typename
id
sha
name
@ -16,10 +23,15 @@ query getFiles(
$nextPageCursor: String
) {
project(fullPath: $projectPath) {
__typename
repository {
__typename
tree(path: $path, ref: $ref) {
__typename
trees(first: $pageSize, after: $nextPageCursor) {
__typename
edges {
__typename
node {
...TreeEntry
webPath
@ -30,7 +42,9 @@ query getFiles(
}
}
submodules(first: $pageSize, after: $nextPageCursor) {
__typename
edges {
__typename
node {
...TreeEntry
webUrl
@ -42,7 +56,9 @@ query getFiles(
}
}
blobs(first: $pageSize, after: $nextPageCursor) {
__typename
edges {
__typename
node {
...TreeEntry
mode

View File

@ -1,6 +1,8 @@
query getPermissions($projectPath: ID!) {
project(fullPath: $projectPath) {
__typename
userPermissions {
__typename
pushCode
forkProject
createMergeRequestIn

View File

@ -30,7 +30,7 @@ module Types
if obj.has_action?
{
button_title: obj.action_button_title,
icon: obj.icon,
icon: obj.action_icon,
method: obj.action_method,
path: obj.action_path,
title: obj.action_title

View File

@ -28,7 +28,8 @@ module SortingHelper
sort_value_contacted_date => sort_title_contacted_date,
sort_value_relative_position => sort_title_relative_position,
sort_value_size => sort_title_size,
sort_value_expire_date => sort_title_expire_date
sort_value_expire_date => sort_title_expire_date,
sort_value_relevant => sort_title_relevant
}
end
@ -81,6 +82,13 @@ module SortingHelper
}
end
def search_reverse_sort_options_hash
{
sort_value_recently_created => sort_value_oldest_created,
sort_value_oldest_created => sort_value_recently_created
}
end
def groups_sort_options_hash
{
sort_value_name => sort_title_name,
@ -218,6 +226,10 @@ module SortingHelper
sort_options_hash[sort_value]
end
def search_sort_option_title(sort_value)
sort_options_hash[sort_value]
end
def sort_direction_icon(sort_value)
case sort_value
when sort_value_milestone, sort_value_due_date, /_asc\z/
@ -256,6 +268,13 @@ module SortingHelper
sort_direction_button(url, reverse_sort, sort_value)
end
def search_sort_direction_button(sort_value)
reverse_sort = search_reverse_sort_options_hash[sort_value]
url = page_filter_path(sort: reverse_sort)
sort_direction_button(url, reverse_sort, sort_value)
end
# Titles.
def sort_title_access_level_asc
s_('SortOptions|Access level, ascending')
@ -421,6 +440,10 @@ module SortingHelper
s_('SortOptions|Expired date')
end
def sort_title_relevant
s_('SortOptions|Relevant')
end
# Values.
def sort_value_access_level_asc
'access_level_asc'
@ -582,6 +605,10 @@ module SortingHelper
'expired_asc'
end
def sort_value_relevant
'relevant'
end
def packages_sort_options_hash
{
sort_value_recently_created => sort_title_created_date,

View File

@ -58,10 +58,12 @@ module Emails
def template_content(email_type)
template = Gitlab::Template::ServiceDeskTemplate.find(email_type, @project)
text = substitute_template_replacements(template.content)
markdown(text, project: @project)
context = { project: @project, pipeline: :email }
context[:author] = @note.author if email_type == 'new_note'
markdown(text, context)
rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
nil
end

View File

@ -10,7 +10,7 @@
.input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url }
.input-group-text
= root_url
= select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace block-truncated', tabindex: 1
= select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace block-truncated'
- else
.input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' }
.input-group-text.border-0
@ -18,4 +18,4 @@
= hidden_field_tag :namespace_id, current_user.namespace_id
.form-group.col-12.col-sm-6.project-path
= label_tag :path, _('Project slug'), class: 'label-bold'
= text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", tabindex: 2, required: true
= text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", required: true

View File

@ -6,7 +6,6 @@
= search_field_tag 'search', nil, placeholder: _('Search or jump to…'),
class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options',
spellcheck: false,
tabindex: '1',
autocomplete: 'off',
data: { issues_path: issues_dashboard_path,
mr_path: merge_requests_dashboard_path,

View File

@ -25,7 +25,7 @@
};
gl.startup_graphql_calls = gl.startup_graphql_calls.map(call => ({
operationName: call.query.match(/^query (.+)\(/)[1],
...call,
fetchCall: fetch(url, {
...opts,
credentials: 'same-origin',

View File

@ -26,6 +26,6 @@
= render 'shared/ref_dropdown', dropdown_class: 'wide'
.form-text.text-muted Existing branch name, tag, or commit SHA
.form-actions
= button_tag 'Create branch', class: 'gl-button btn btn-success', tabindex: 3
= button_tag 'Create branch', class: 'gl-button btn btn-success'
= link_to 'Cancel', project_branches_path(@project), class: 'gl-button btn btn-cancel'
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe

View File

@ -16,4 +16,4 @@
= render "shared/import_form", f: f
.form-actions
= f.submit 'Start import', class: "gl-button btn btn-success", tabindex: 4
= f.submit 'Start import', class: "gl-button btn btn-success"

View File

@ -39,5 +39,5 @@
= f.check_box :active, required: false, value: @schedule.active?
= f.label :active, _('Active'), class: 'gl-font-weight-normal'
.footer-block.row-content-block
= f.submit _('Save pipeline schedule'), class: 'btn btn-success', tabindex: 3
= f.submit _('Save pipeline schedule'), class: 'btn btn-success'
= link_to _('Cancel'), pipeline_schedules_path(@project), class: 'btn btn-cancel'

View File

@ -48,7 +48,7 @@
= (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe
.form-actions
= f.submit s_('Pipeline|Run Pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3
= f.submit s_('Pipeline|Run Pipeline'), class: 'btn btn-success js-variables-save-button'
= link_to _('Cancel'), project_pipelines_path(@project), class: 'btn btn-default float-right'
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe

View File

@ -1,5 +1,7 @@
- current_route_path = request.fullpath.match(/-\/tree\/[^\/]+\/(.+$)/).to_a[1]
- add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path })
- add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" })
- add_page_startup_graphql_call('repository/permissions', { projectPath: @project.full_path })
- add_page_startup_graphql_call('repository/files', { nextPageCursor: "", pageSize: 100, projectPath: @project.full_path, ref: current_ref, path: current_route_path || "/"})
- breadcrumb_title _("Repository")
- @content_class = "limit-container-width" unless fluid_layout

View File

@ -3,22 +3,26 @@
= render partial: "search/results/empty"
= render_if_exists 'shared/promotions/promote_advanced_search'
- else
.row-content-block.d-md-flex.text-left.align-items-center
- unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount)
= search_entries_info(@search_objects, @scope, @search_term)
- unless @show_snippets
- if @project
- link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1')
- if @scope == 'blobs'
= s_("SearchCodeResults|in")
.mx-md-1
= render partial: "shared/ref_switcher", locals: { ref: repository_ref(@project), form_path: request.fullpath, field_name: 'repository_ref' }
= s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project }
- else
= _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project }
- elsif @group
- link_to_group = link_to(@group.name, @group, class: 'ml-md-1')
= _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group }
.search-results-status
.row-content-block.gl-display-flex
.gl-display-md-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1
- unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount)
= search_entries_info(@search_objects, @scope, @search_term)
- unless @show_snippets
- if @project
- link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1')
- if @scope == 'blobs'
= s_("SearchCodeResults|in")
.mx-md-1
= render partial: "shared/ref_switcher", locals: { ref: repository_ref(@project), form_path: request.fullpath, field_name: 'repository_ref' }
= s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project }
- else
= _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project }
- elsif @group
- link_to_group = link_to(@group.name, @group, class: 'ml-md-1')
= _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group }
.gl-display-md-flex.gl-flex-direction-column
= render partial: 'search/sort_dropdown'
= render_if_exists 'shared/promotions/promote_advanced_search'
= render partial: "search/results/filters"

View File

@ -0,0 +1,16 @@
- return unless ['issues', 'merge_requests'].include?(@scope)
- sort_value = @sort
- sort_title = search_sort_option_title(sort_value)
.dropdown.gl-display-inline-block.gl-ml-3.filter-dropdown-container
.btn-group{ role: 'group' }
.btn-group{ role: 'group' }
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
= sort_title
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li
= render_if_exists('search/sort_by_relevancy', sort_title: sort_title)
= sortable_item(sort_title_recently_created, page_filter_path(sort: sort_value_recently_created), sort_title)
= search_sort_direction_button(sort_value)

View File

@ -1,2 +1,2 @@
= form_tag request.path, method: :get, class: "group-filter-form js-group-filter-form", id: 'group-filter-form' do |f|
= search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter qa-groups-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
= search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter qa-groups-filter', spellcheck: false, id: 'group-filter-form-field'

View File

@ -7,7 +7,6 @@
class: "project-filter-form-field form-control #{form_field_classes}",
spellcheck: false,
id: 'project-filter-form-field',
tabindex: "2",
autofocus: local_assigns[:autofocus]
- if local_assigns[:icon]

View File

@ -0,0 +1,5 @@
---
title: Add ability to sort search results for issues and merge requests
merge_request: 45003
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Remove positive tabindexes
merge_request: 46003
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Copyedit Project Issue Boards API docs
merge_request: 46110
author: Takuya Noguchi
type: fixed

View File

@ -0,0 +1,6 @@
---
title: Manually collapsed diff files are now significantly shorter and less visually
intrusive
merge_request: 43911
author:
type: changed

View File

@ -0,0 +1,6 @@
---
title: Render correct URLs for uploads in service desk issues when custom template
is used.
merge_request: 45772
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Gracefully recover from deleted LFS file
merge_request: 45459
author:
type: fixed

View File

@ -4,16 +4,16 @@ group: Project Management
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
# Issue Boards API
# Project Issue Boards API
Every API call to boards must be authenticated.
If a user is not a member of a project and the project is private, a `GET`
request on that project will result to a `404` status code.
## Project Board
## List project issue boards
Lists Issue Boards in the given project.
Lists project issue boards in the given project.
```plaintext
GET /projects/:id/boards
@ -88,9 +88,15 @@ Example response:
]
```
## Single board
Another example response when no board has been activated or exist in the project:
Get a single board.
```json
[]
```
## Show a single issue board
Get a single project issue board.
```plaintext
GET /projects/:id/boards/:board_id
@ -165,9 +171,9 @@ Example response:
}
```
## Create a board **(STARTER)**
## Create an issue board **(STARTER)**
Creates a board.
Creates a project issue board.
```plaintext
POST /projects/:id/boards
@ -242,11 +248,11 @@ Example response:
}
```
## Update a board **(STARTER)**
## Update an issue board **(STARTER)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/5954) in [GitLab Starter](https://about.gitlab.com/pricing/) 11.1.
Updates a board.
Updates a project issue board.
```plaintext
PUT /projects/:id/boards/:board_id
@ -323,9 +329,9 @@ Example response:
}
```
## Delete a board **(STARTER)**
## Delete an issue board **(STARTER)**
Deletes a board.
Deletes a project issue board.
```plaintext
DELETE /projects/:id/boards/:board_id
@ -340,10 +346,10 @@ DELETE /projects/:id/boards/:board_id
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/boards/1"
```
## List board lists
## List board lists in a project issue board
Get a list of the board's lists.
Does not include `open` and `closed` lists
Does not include `open` and `closed` lists.
```plaintext
GET /projects/:id/boards/:board_id/lists
@ -401,7 +407,7 @@ Example response:
]
```
## Single board list
## Show a single board list
Get a single board list.
@ -436,9 +442,9 @@ Example response:
}
```
## New board list
## Create a board list
Creates a new Issue Board list.
Creates a new issue board list.
```plaintext
POST /projects/:id/boards/:board_id/lists
@ -479,9 +485,9 @@ Example response:
}
```
## Edit board list
## Reorder a list in a board
Updates an existing Issue Board list. This call is used to change list position.
Updates an existing issue board list. This call is used to change list position.
```plaintext
PUT /projects/:id/boards/:board_id/lists/:list_id
@ -515,7 +521,7 @@ Example response:
}
```
## Delete a board list
## Delete a board list from a board
Only for admins and project owners. Deletes the board list in question.

View File

@ -356,9 +356,11 @@ Here are the requirements for using dependency scanning in an offline environmen
- GitLab Runner with the [`docker` or `kubernetes` executor](#requirements).
- Docker Container Registry with locally available copies of dependency scanning [analyzer](https://gitlab.com/gitlab-org/security-products/analyzers) images.
- Host an offline Git copy of the [gemnasium-db advisory database](https://gitlab.com/gitlab-org/security-products/gemnasium-db/).
This is required because, in an offline environment, the Gemnasium analyzer can't fetch the latest
advisories from the online repository.
- If you have a limited access environment you will need to allow access, such as using a proxy, to the advisory database: `https://gitlab.com/gitlab-org/security-products/gemnasium-db.git`.
If you are unable to permit access to `https://gitlab.com/gitlab-org/security-products/gemnasium-db.git` you must host an offline copy of this `git` repository and set the `GEMNASIUM_DB_REMOTE_URL` variable to the URL of this repository. For more information on configuration variables, see [Dependency Scanning](#configuring-dependency-scanning).
This advisory database is constantly being updated, so you will need to periodically sync your local copy with GitLab's.
- _Only if scanning Ruby projects_: Host an offline Git copy of the [advisory database](https://github.com/rubysec/ruby-advisory-db).
- _Only if scanning npm/yarn projects_: Host an offline copy of the [retire.js](https://github.com/RetireJS/retire.js/) [node](https://github.com/RetireJS/retire.js/blob/master/repository/npmrepository.json) and [js](https://github.com/RetireJS/retire.js/blob/master/repository/jsrepository.json) advisory databases.

View File

@ -9,15 +9,18 @@ info: To determine the technical writer assigned to the Stage/Group associated w
GitLab provides a comprehensive set of features for viewing and managing vulnerabilities:
- Security dashboards: An overview of the security status in your instance, groups, and projects.
- Vulnerability reports: Detailed lists of all vulnerabilities for the instance, group, project, or
- Security dashboards: An overview of the security status in your instance, [groups](#group-security-dashboard), and
[projects](#project-security-dashboard).
- [Vulnerability reports](#vulnerability-report): Detailed lists of all vulnerabilities for the instance, group, project, or
pipeline. This is where you triage and manage vulnerabilities.
- Security Center: A dedicated area for vulnerability management at the instance level. This
- [Security Center](#instance-security-center): A dedicated area for vulnerability management at the instance level. This
includes a security dashboard, vulnerability report, and settings.
You can also drill down into a vulnerability and get extra information. This includes the project it
comes from, any related file(s), and metadata that helps you analyze the risk it poses. You can also
dismiss a vulnerability or create an issue for it.
You can also drill down into a vulnerability and get extra information on the
[Vulnerability Page](../vulnerabilities/index.md). This view includes the project it
comes from, any related file(s), and metadata that helps you analyze the risk it poses.
You can also confirm, dismiss, or resolve a vulnerability, create an issue for it,
and in some cases, generate a merge request to fix the vulnerability.
To benefit from these features, you must first configure one of the
[security scanners](../index.md).
@ -30,7 +33,7 @@ The vulnerability report displays vulnerabilities detected by scanners such as:
- [Dynamic Application Security Testing](../dast/index.md)
- [Dependency Scanning](../dependency_scanning/index.md)
- [Static Application Security Testing](../sast/index.md)
- And others!
- And [others](../index.md#security-scanning-tools)!
## Requirements
@ -64,10 +67,10 @@ the analyzer outputs an
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/6165) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.1.
At the project level, the Security Dashboard displays the vulnerabilities merged into your project's
At the project level, the Security Dashboard displays the vulnerabilities that exist in your project's
[default branch](../../project/repository/branches/index.md#default-branch). Access it by navigating
to **Security & Compliance > Security Dashboard**. By default, the Security Dashboard displays all
detected and confirmed vulnerabilities.
to **Security & Compliance > Security Dashboard**. By default, the Security Dashboard is filtered to
display all detected and confirmed vulnerabilities.
The Security Dashboard first displays the time at which the last pipeline completed on the project's
default branch. There's also a link to view this in more detail.
@ -81,9 +84,11 @@ page to view more information about that vulnerability.
You can filter the vulnerabilities by one or more of the following:
- Status
- Severity
- Scanner
| Filter | Available Options |
| --- | --- |
| Status | Detected, Confirmed, Dismissed, Resolved |
| Severity | Critical, High, Medium, Low, Info, Unknown |
| Scanner | [Available Scanners](../index.md#security-scanning-tools) |
You can also dismiss vulnerabilities in the table:
@ -96,7 +101,7 @@ You can also dismiss vulnerabilities in the table:
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/6709) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.5.
The group Security Dashboard gives an overview of the vulnerabilities in the default branches of the
The group Security Dashboard gives an overview of the vulnerabilities found in the default branches of the
projects in a group and its subgroups. Access it by navigating to **Security > Security Dashboard**
after selecting your group. By default, the Security Dashboard displays all detected and confirmed
vulnerabilities. If you don't see the vulnerabilities over time graph, the likely cause is that you
@ -115,11 +120,12 @@ more details about the open vulnerabilities at a specific time.
Next to the timeline chart is a list of projects, grouped and sorted by the severity of the vulnerability found:
- F: One or more "critical"
- D: One or more "high" or "unknown"
- C: One or more "medium"
- B: One or more "low"
- A: Zero vulnerabilities
| Grade | Description |
| F | One or more "critical" |
| D | One or more "high" or "unknown" |
| C | One or more "medium" |
| B | One or more "low" |
| A | Zero vulnerabilities |
Projects with no vulnerability tests configured will not appear in the list. Additionally, dismissed
vulnerabilities are excluded.
@ -232,10 +238,12 @@ into the default branch.
You can filter which vulnerabilities the vulnerability report displays by:
- Status
- Severity
- Scanner
- Project
| Filter | Available Options |
| --- | --- |
| Status | Detected, Confirmed, Dismissed, Resolved |
| Severity | Critical, High, Medium, Low, Info, Unknown |
| Scanner | [Available Scanners](../index.md#security-scanning-tools) |
| Project | Projects configured in the Security Center settings |
Clicking any vulnerability in the table takes you to its
[Vulnerability Details](../vulnerabilities) page to see more information on that vulnerability.

View File

@ -129,12 +129,12 @@ module Gitlab
# rubocop: disable CodeReuse/ActiveRecord
def apply_sort(scope)
case sort
when 'oldest'
when 'created_asc'
scope.reorder('created_at ASC')
when 'newest'
when 'created_desc'
scope.reorder('created_at DESC')
else
scope
scope.reorder('created_at DESC')
end
end
# rubocop: enable CodeReuse/ActiveRecord

View File

@ -24949,6 +24949,9 @@ msgstr ""
msgid "SortOptions|Recently starred"
msgstr ""
msgid "SortOptions|Relevant"
msgstr ""
msgid "SortOptions|Size"
msgstr ""

View File

@ -127,6 +127,41 @@ RSpec.describe Repositories::LfsStorageController do
end
end
context 'when existing file has been deleted' do
let(:lfs_object) { create(:lfs_object, :with_file) }
before do
FileUtils.rm(lfs_object.file.path)
params[:oid] = lfs_object.oid
params[:size] = lfs_object.size
end
it 'replaces the file' do
expect(Gitlab::AppJsonLogger).to receive(:info).with(message: "LFS file replaced because it did not exist", oid: lfs_object.oid, size: lfs_object.size)
subject
expect(response).to have_gitlab_http_status(:ok)
expect(lfs_object.reload.file).to exist
end
context 'with invalid file' do
before do
allow_next_instance_of(ActionController::Parameters) do |params|
allow(params).to receive(:[]).and_call_original
allow(params).to receive(:[]).with(:file).and_return({})
end
end
it 'renders LFS forbidden' do
subject
expect(response).to have_gitlab_http_status(:forbidden)
expect(lfs_object.reload.file).not_to exist
end
end
end
context 'when file is not stored' do
it 'renders unprocessable entity' do
expect(controller).to receive(:store_file!).and_return(nil)

View File

@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe 'User searches for issues', :js do
let(:user) { create(:user) }
let(:project) { create(:project, namespace: user.namespace) }
let!(:issue1) { create(:issue, title: 'Foo', project: project) }
let!(:issue2) { create(:issue, :closed, :confidential, title: 'Bar', project: project) }
let!(:issue1) { create(:issue, title: 'issue Foo', project: project, created_at: 1.hour.ago) }
let!(:issue2) { create(:issue, :closed, :confidential, title: 'issue Bar', project: project) }
def search_for_issue(search)
fill_in('dashboard_search', with: search)
@ -67,6 +67,22 @@ RSpec.describe 'User searches for issues', :js do
end
end
it 'sorts by created date' do
search_for_issue('issue')
page.within('.results') do
expect(page.all('.search-result-row').first).to have_link(issue2.title)
expect(page.all('.search-result-row').last).to have_link(issue1.title)
end
find('.reverse-sort-btn').click
page.within('.results') do
expect(page.all('.search-result-row').first).to have_link(issue1.title)
expect(page.all('.search-result-row').last).to have_link(issue2.title)
end
end
context 'when on a project page' do
it 'finds an issue' do
find('.js-search-project-dropdown').click

View File

@ -697,7 +697,7 @@ describe('diffs/components/app', () => {
});
describe('collapsed files', () => {
it('should render the collapsed files warning if there are any collapsed files', () => {
it('should render the collapsed files warning if there are any automatically collapsed files', () => {
createComponent({}, ({ state }) => {
state.diffs.diffFiles = [{ viewer: { automaticallyCollapsed: true } }];
});
@ -705,16 +705,14 @@ describe('diffs/components/app', () => {
expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true);
});
it('should not render the collapsed files warning if the user has dismissed the alert already', async () => {
it('should not render the collapsed files warning if there are no automatically collapsed files', () => {
createComponent({}, ({ state }) => {
state.diffs.diffFiles = [{ viewer: { automaticallyCollapsed: true } }];
state.diffs.diffFiles = [
{ viewer: { automaticallyCollapsed: false, manuallyCollapsed: true } },
{ viewer: { automaticallyCollapsed: false, manuallyCollapsed: false } },
];
});
expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true);
wrapper.vm.collapsedWarningDismissed = true;
await wrapper.vm.$nextTick();
expect(getCollapsedFilesWarning(wrapper).exists()).toBe(false);
});
});

View File

@ -1,6 +1,9 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { cloneDeep } from 'lodash';
import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import diffDiscussionsMockData from '../mock_data/diff_discussions';
@ -136,9 +139,25 @@ describe('DiffFileHeader component', () => {
});
});
it('displays a copy to clipboard button', () => {
createComponent();
expect(wrapper.find(ClipboardButton).exists()).toBe(true);
describe('copy to clipboard', () => {
beforeEach(() => {
createComponent();
});
it('displays a copy to clipboard button', () => {
expect(wrapper.find(ClipboardButton).exists()).toBe(true);
});
it('triggers the copy to clipboard tracking event', () => {
const trackingSpy = mockTracking('_category_', wrapper.vm.$el, jest.spyOn);
triggerEvent('[data-testid="diff-file-copy-clipboard"]');
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_copy_file_button', {
label: 'diff_copy_file_path_button',
property: 'diff_copy_file',
});
});
});
describe('for submodule', () => {

View File

@ -1,262 +1,317 @@
import Vue from 'vue';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
import { createStore } from '~/mr_notes/stores';
import DiffFileComponent from '~/diffs/components/diff_file.vue';
import { diffViewerModes, diffViewerErrors } from '~/ide/constants';
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createDiffsStore from '~/diffs/store/modules';
import diffFileMockDataReadable from '../mock_data/diff_file';
import diffFileMockDataUnreadable from '../mock_data/diff_file_unreadable';
describe('DiffFile', () => {
let vm;
let trackingSpy;
import DiffFileComponent from '~/diffs/components/diff_file.vue';
import DiffFileHeaderComponent from '~/diffs/components/diff_file_header.vue';
import DiffContentComponent from '~/diffs/components/diff_content.vue';
beforeEach(() => {
vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), {
file: JSON.parse(JSON.stringify(diffFileMockDataReadable)),
import { diffViewerModes, diffViewerErrors } from '~/ide/constants';
function changeViewer(store, index, { automaticallyCollapsed, manuallyCollapsed, name }) {
const file = store.state.diffs.diffFiles[index];
const newViewer = {
...file.viewer,
};
if (automaticallyCollapsed !== undefined) {
newViewer.automaticallyCollapsed = automaticallyCollapsed;
}
if (manuallyCollapsed !== undefined) {
newViewer.manuallyCollapsed = manuallyCollapsed;
}
if (name !== undefined) {
newViewer.name = name;
}
Object.assign(file, {
viewer: newViewer,
});
}
function forceHasDiff({ store, index = 0, inlineLines, parallelLines, readableText }) {
const file = store.state.diffs.diffFiles[index];
Object.assign(file, {
highlighted_diff_lines: inlineLines,
parallel_diff_lines: parallelLines,
blob: {
...file.blob,
readable_text: readableText,
},
});
}
function markFileToBeRendered(store, index = 0) {
const file = store.state.diffs.diffFiles[index];
Object.assign(file, {
renderIt: true,
});
}
function createComponent({ file }) {
const localVue = createLocalVue();
localVue.use(Vuex);
const store = new Vuex.Store({
modules: {
diffs: createDiffsStore(),
},
});
store.state.diffs.diffFiles = [file];
const wrapper = shallowMount(DiffFileComponent, {
store,
localVue,
propsData: {
file,
canCurrentUserFork: false,
viewDiffsFileByFile: false,
}).$mount();
trackingSpy = mockTracking('_category_', vm.$el, jest.spyOn);
},
});
return {
localVue,
wrapper,
store,
};
}
const findDiffHeader = wrapper => wrapper.find(DiffFileHeaderComponent);
const findDiffContentArea = wrapper => wrapper.find('[data-testid="content-area"]');
const findLoader = wrapper => wrapper.find('[data-testid="loader-icon"]');
const findToggleLinks = wrapper => wrapper.findAll('[data-testid="toggle-link"]');
const toggleFile = wrapper => findDiffHeader(wrapper).vm.$emit('toggleFile');
const isDisplayNone = element => element.style.display === 'none';
const getReadableFile = () => JSON.parse(JSON.stringify(diffFileMockDataReadable));
const getUnreadableFile = () => JSON.parse(JSON.stringify(diffFileMockDataUnreadable));
const makeFileAutomaticallyCollapsed = (store, index = 0) =>
changeViewer(store, index, { automaticallyCollapsed: true, manuallyCollapsed: null });
const makeFileOpenByDefault = (store, index = 0) =>
changeViewer(store, index, { automaticallyCollapsed: false, manuallyCollapsed: null });
const makeFileManuallyCollapsed = (store, index = 0) =>
changeViewer(store, index, { automaticallyCollapsed: false, manuallyCollapsed: true });
const changeViewerType = (store, newType, index = 0) =>
changeViewer(store, index, { name: diffViewerModes[newType] });
describe('DiffFile', () => {
let wrapper;
let store;
beforeEach(() => {
({ wrapper, store } = createComponent({ file: getReadableFile() }));
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
const findDiffContent = () => vm.$el.querySelector('.diff-content');
const isVisible = el => el.style.display !== 'none';
describe('template', () => {
it('should render component with file header, file content components', done => {
const el = vm.$el;
const { file_hash, file_path } = vm.file;
it('should render component with file header, file content components', async () => {
const el = wrapper.vm.$el;
const { file_hash } = wrapper.vm.file;
expect(el.id).toEqual(file_hash);
expect(el.classList.contains('diff-file')).toEqual(true);
expect(el.querySelectorAll('.diff-content.hidden').length).toEqual(0);
expect(el.querySelector('.js-file-title')).toBeDefined();
expect(el.querySelector('[data-testid="diff-file-copy-clipboard"]')).toBeDefined();
expect(el.querySelector('.file-title-name').innerText.indexOf(file_path)).toBeGreaterThan(-1);
expect(wrapper.find(DiffFileHeaderComponent).exists()).toBe(true);
expect(el.querySelector('.js-syntax-highlight')).toBeDefined();
vm.file.renderIt = true;
markFileToBeRendered(store);
vm.$nextTick()
.then(() => {
expect(el.querySelectorAll('.line_content').length).toBe(8);
expect(el.querySelectorAll('.js-line-expansion-content').length).toBe(1);
triggerEvent('[data-testid="diff-file-copy-clipboard"]');
})
.then(done)
.catch(done.fail);
await wrapper.vm.$nextTick();
expect(wrapper.find(DiffContentComponent).exists()).toBe(true);
});
});
describe('collapsing', () => {
describe('user collapsed', () => {
beforeEach(() => {
makeFileManuallyCollapsed(store);
});
it('should not have any content at all', async () => {
await wrapper.vm.$nextTick();
Array.from(findDiffContentArea(wrapper).element.children).forEach(child => {
expect(isDisplayNone(child)).toBe(true);
});
});
it('should not have the class `has-body` to present the header differently', () => {
expect(wrapper.classes('has-body')).toBe(false);
});
});
it('should track a click event on copy to clip board button', done => {
const el = vm.$el;
describe('automatically collapsed', () => {
beforeEach(() => {
makeFileAutomaticallyCollapsed(store);
});
expect(el.querySelector('[data-testid="diff-file-copy-clipboard"]')).toBeDefined();
vm.file.renderIt = true;
vm.$nextTick()
.then(() => {
triggerEvent('[data-testid="diff-file-copy-clipboard"]');
it('should show the collapsed file warning with expansion link', () => {
expect(findDiffContentArea(wrapper).html()).toContain('This diff is collapsed');
expect(findToggleLinks(wrapper).length).toEqual(1);
});
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_copy_file_button', {
label: 'diff_copy_file_path_button',
property: 'diff_copy_file',
});
})
.then(done)
.catch(done.fail);
it('should style the component so that it `.has-body` for layout purposes', () => {
expect(wrapper.classes('has-body')).toBe(true);
});
});
describe('collapsed', () => {
it('should not have file content', done => {
expect(isVisible(findDiffContent())).toBe(true);
expect(vm.isCollapsed).toEqual(false);
vm.isCollapsed = true;
vm.file.renderIt = true;
describe('not collapsed', () => {
beforeEach(() => {
makeFileOpenByDefault(store);
markFileToBeRendered(store);
});
vm.$nextTick(() => {
expect(isVisible(findDiffContent())).toBe(false);
it('should have the file content', async () => {
expect(wrapper.find(DiffContentComponent).exists()).toBe(true);
});
done();
it('should style the component so that it `.has-body` for layout purposes', () => {
expect(wrapper.classes('has-body')).toBe(true);
});
});
describe('toggle', () => {
it('should update store state', async () => {
jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(() => {});
toggleFile(wrapper);
expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('diffs/setFileCollapsedByUser', {
filePath: wrapper.vm.file.file_path,
collapsed: true,
});
});
it('should have collapsed text and link', done => {
vm.renderIt = true;
vm.isCollapsed = true;
vm.$nextTick(() => {
expect(vm.$el.innerText).toContain('This diff is collapsed');
expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
done();
});
});
it('should have collapsed text and link even before rendered', done => {
vm.renderIt = false;
vm.isCollapsed = true;
vm.$nextTick(() => {
expect(vm.$el.innerText).toContain('This diff is collapsed');
expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
done();
});
});
it('should be collapsable for unreadable files', done => {
vm.$destroy();
vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), {
file: JSON.parse(JSON.stringify(diffFileMockDataUnreadable)),
canCurrentUserFork: false,
viewDiffsFileByFile: false,
}).$mount();
vm.renderIt = false;
vm.isCollapsed = true;
vm.$nextTick(() => {
expect(vm.$el.innerText).toContain('This diff is collapsed');
expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
done();
});
});
it('should be collapsed for renamed files', done => {
vm.renderIt = true;
vm.isCollapsed = false;
vm.file.highlighted_diff_lines = null;
vm.file.viewer.name = diffViewerModes.renamed;
vm.$nextTick(() => {
expect(vm.$el.innerText).not.toContain('This diff is collapsed');
done();
});
});
it('should be collapsed for mode changed files', done => {
vm.renderIt = true;
vm.isCollapsed = false;
vm.file.highlighted_diff_lines = null;
vm.file.viewer.name = diffViewerModes.mode_changed;
vm.$nextTick(() => {
expect(vm.$el.innerText).not.toContain('This diff is collapsed');
done();
});
});
it('should have loading icon while loading a collapsed diffs', done => {
vm.isCollapsed = true;
vm.isLoadingCollapsedDiff = true;
vm.$nextTick(() => {
expect(vm.$el.querySelectorAll('.diff-content.loading').length).toEqual(1);
done();
});
});
it('should update store state', done => {
jest.spyOn(vm.$store, 'dispatch').mockImplementation(() => {});
vm.isCollapsed = true;
vm.$nextTick(() => {
expect(vm.$store.dispatch).toHaveBeenCalledWith('diffs/setFileCollapsed', {
filePath: vm.file.file_path,
collapsed: true,
describe('fetch collapsed diff', () => {
const prepFile = async (inlineLines, parallelLines, readableText) => {
forceHasDiff({
store,
inlineLines,
parallelLines,
readableText,
});
done();
await wrapper.vm.$nextTick();
toggleFile(wrapper);
};
beforeEach(() => {
jest.spyOn(wrapper.vm, 'requestDiff').mockImplementation(() => {});
makeFileAutomaticallyCollapsed(store);
});
it.each`
inlineLines | parallelLines | readableText
${[1]} | ${[1]} | ${true}
${[]} | ${[1]} | ${true}
${[1]} | ${[]} | ${true}
${[1]} | ${[1]} | ${false}
${[]} | ${[]} | ${false}
`(
'does not make a request to fetch the diff for a diff file like { inline: $inlineLines, parallel: $parallelLines, readableText: $readableText }',
async ({ inlineLines, parallelLines, readableText }) => {
await prepFile(inlineLines, parallelLines, readableText);
expect(wrapper.vm.requestDiff).not.toHaveBeenCalled();
},
);
it.each`
inlineLines | parallelLines | readableText
${[]} | ${[]} | ${true}
`(
'makes a request to fetch the diff for a diff file like { inline: $inlineLines, parallel: $parallelLines, readableText: $readableText }',
async ({ inlineLines, parallelLines, readableText }) => {
await prepFile(inlineLines, parallelLines, readableText);
expect(wrapper.vm.requestDiff).toHaveBeenCalled();
},
);
});
});
describe('loading', () => {
it('should have loading icon while loading a collapsed diffs', async () => {
makeFileAutomaticallyCollapsed(store);
wrapper.vm.isLoadingCollapsedDiff = true;
await wrapper.vm.$nextTick();
expect(findLoader(wrapper).exists()).toBe(true);
});
});
describe('general (other) collapsed', () => {
it('should be expandable for unreadable files', async () => {
({ wrapper, store } = createComponent({ file: getUnreadableFile() }));
makeFileAutomaticallyCollapsed(store);
await wrapper.vm.$nextTick();
expect(findDiffContentArea(wrapper).html()).toContain('This diff is collapsed');
expect(findToggleLinks(wrapper).length).toEqual(1);
});
it('updates local state when changing file state', done => {
vm.file.viewer.automaticallyCollapsed = true;
it.each`
mode
${'renamed'}
${'mode_changed'}
`(
'should render the DiffContent component for files whose mode is $mode',
async ({ mode }) => {
makeFileOpenByDefault(store);
markFileToBeRendered(store);
changeViewerType(store, mode);
vm.$nextTick(() => {
expect(vm.isCollapsed).toBe(true);
await wrapper.vm.$nextTick();
done();
});
});
expect(wrapper.classes('has-body')).toBe(true);
expect(wrapper.find(DiffContentComponent).exists()).toBe(true);
expect(wrapper.find(DiffContentComponent).isVisible()).toBe(true);
},
);
});
});
describe('too large diff', () => {
it('should have too large warning and blob link', done => {
it('should have too large warning and blob link', async () => {
const file = store.state.diffs.diffFiles[0];
const BLOB_LINK = '/file/view/path';
vm.file.viewer.error = diffViewerErrors.too_large;
vm.file.viewer.error_message =
'This source diff could not be displayed because it is too large';
vm.file.view_path = BLOB_LINK;
vm.file.renderIt = true;
vm.$nextTick(() => {
expect(vm.$el.innerText).toContain(
'This source diff could not be displayed because it is too large',
);
done();
Object.assign(store.state.diffs.diffFiles[0], {
...file,
view_path: BLOB_LINK,
renderIt: true,
viewer: {
...file.viewer,
error: diffViewerErrors.too_large,
error_message: 'This source diff could not be displayed because it is too large',
},
});
});
});
describe('watch collapsed', () => {
it('calls handleLoadCollapsedDiff if collapsed changed & file has no lines', done => {
jest.spyOn(vm, 'handleLoadCollapsedDiff').mockImplementation(() => {});
await wrapper.vm.$nextTick();
vm.file.highlighted_diff_lines = [];
vm.file.parallel_diff_lines = [];
vm.isCollapsed = true;
vm.$nextTick()
.then(() => {
vm.isCollapsed = false;
return vm.$nextTick();
})
.then(() => {
expect(vm.handleLoadCollapsedDiff).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('does not call handleLoadCollapsedDiff if collapsed changed & file is unreadable', done => {
vm.$destroy();
vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), {
file: JSON.parse(JSON.stringify(diffFileMockDataUnreadable)),
canCurrentUserFork: false,
viewDiffsFileByFile: false,
}).$mount();
jest.spyOn(vm, 'handleLoadCollapsedDiff').mockImplementation(() => {});
vm.file.highlighted_diff_lines = [];
vm.file.parallel_diff_lines = undefined;
vm.isCollapsed = true;
vm.$nextTick()
.then(() => {
vm.isCollapsed = false;
return vm.$nextTick();
})
.then(() => {
expect(vm.handleLoadCollapsedDiff).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
expect(wrapper.vm.$el.innerText).toContain(
'This source diff could not be displayed because it is too large',
);
});
});
});

View File

@ -27,6 +27,7 @@ export default {
name: 'text',
error: null,
automaticallyCollapsed: false,
manuallyCollapsed: null,
},
added_lines: 2,
removed_lines: 0,

View File

@ -26,6 +26,7 @@ export default {
name: 'text',
error: null,
automaticallyCollapsed: false,
manuallyCollapsed: null,
},
added_lines: 0,
removed_lines: 0,

View File

@ -42,7 +42,7 @@ import {
fetchFullDiff,
toggleFullDiff,
switchToFullDiffFromRenamedFile,
setFileCollapsed,
setFileCollapsedByUser,
setExpandedDiffLines,
setSuggestPopoverDismissed,
changeCurrentCommit,
@ -1216,13 +1216,18 @@ describe('DiffsStoreActions', () => {
});
});
describe('setFileCollapsed', () => {
describe('setFileUserCollapsed', () => {
it('commits SET_FILE_COLLAPSED', done => {
testAction(
setFileCollapsed,
setFileCollapsedByUser,
{ filePath: 'test', collapsed: true },
null,
[{ type: types.SET_FILE_COLLAPSED, payload: { filePath: 'test', collapsed: true } }],
[
{
type: types.SET_FILE_COLLAPSED,
payload: { filePath: 'test', collapsed: true, trigger: 'manual' },
},
],
[],
done,
);

View File

@ -49,23 +49,53 @@ describe('Diffs Module Getters', () => {
});
});
describe('hasCollapsedFile', () => {
it('returns true when all files are collapsed', () => {
localState.diffFiles = [
{ viewer: { automaticallyCollapsed: true } },
{ viewer: { automaticallyCollapsed: true } },
];
describe('whichCollapsedTypes', () => {
const autoCollapsedFile = { viewer: { automaticallyCollapsed: true, manuallyCollapsed: null } };
const manuallyCollapsedFile = {
viewer: { automaticallyCollapsed: false, manuallyCollapsed: true },
};
const openFile = { viewer: { automaticallyCollapsed: false, manuallyCollapsed: false } };
expect(getters.hasCollapsedFile(localState)).toEqual(true);
it.each`
description | value | files
${'all files are automatically collapsed'} | ${true} | ${[{ ...autoCollapsedFile }, { ...autoCollapsedFile }]}
${'all files are manually collapsed'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...manuallyCollapsedFile }]}
${'no files are collapsed in any way'} | ${false} | ${[{ ...openFile }, { ...openFile }]}
${'some files are collapsed in either way'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...autoCollapsedFile }, { ...openFile }]}
`('`any` is $value when $description', ({ value, files }) => {
localState.diffFiles = files;
const getterResult = getters.whichCollapsedTypes(localState);
expect(getterResult.any).toEqual(value);
});
it('returns true when at least one file is collapsed', () => {
localState.diffFiles = [
{ viewer: { automaticallyCollapsed: false } },
{ viewer: { automaticallyCollapsed: true } },
];
it.each`
description | value | files
${'all files are automatically collapsed'} | ${true} | ${[{ ...autoCollapsedFile }, { ...autoCollapsedFile }]}
${'all files are manually collapsed'} | ${false} | ${[{ ...manuallyCollapsedFile }, { ...manuallyCollapsedFile }]}
${'no files are collapsed in any way'} | ${false} | ${[{ ...openFile }, { ...openFile }]}
${'some files are collapsed in either way'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...autoCollapsedFile }, { ...openFile }]}
`('`automatic` is $value when $description', ({ value, files }) => {
localState.diffFiles = files;
expect(getters.hasCollapsedFile(localState)).toEqual(true);
const getterResult = getters.whichCollapsedTypes(localState);
expect(getterResult.automatic).toEqual(value);
});
it.each`
description | value | files
${'all files are automatically collapsed'} | ${false} | ${[{ ...autoCollapsedFile }, { ...autoCollapsedFile }]}
${'all files are manually collapsed'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...manuallyCollapsedFile }]}
${'no files are collapsed in any way'} | ${false} | ${[{ ...openFile }, { ...openFile }]}
${'some files are collapsed in either way'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...autoCollapsedFile }, { ...openFile }]}
`('`manual` is $value when $description', ({ value, files }) => {
localState.diffFiles = files;
const getterResult = getters.whichCollapsedTypes(localState);
expect(getterResult.manual).toEqual(value);
});
});

View File

@ -0,0 +1,375 @@
import { ApolloLink, Observable } from 'apollo-link';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
describe('StartupJSLink', () => {
const FORWARDED_RESPONSE = { data: 'FORWARDED_RESPONSE' };
const STARTUP_JS_RESPONSE = { data: 'STARTUP_JS_RESPONSE' };
const OPERATION_NAME = 'startupJSQuery';
const STARTUP_JS_QUERY = `query ${OPERATION_NAME}($id: Int = 3){
name
id
}`;
const STARTUP_JS_RESPONSE_TWO = { data: 'STARTUP_JS_RESPONSE_TWO' };
const OPERATION_NAME_TWO = 'startupJSQueryTwo';
const STARTUP_JS_QUERY_TWO = `query ${OPERATION_NAME_TWO}($id: Int = 3){
id
name
}`;
const ERROR_RESPONSE = {
data: {
user: null,
},
errors: [
{
path: ['user'],
locations: [{ line: 2, column: 3 }],
extensions: {
message: 'Object not found',
type: 2,
},
},
],
};
let startupLink;
let link;
function mockFetchCall(status = 200, response = STARTUP_JS_RESPONSE) {
const p = {
ok: status >= 200 && status < 300,
status,
headers: new Headers({ 'Content-Type': 'application/json' }),
statusText: `MOCK-FETCH ${status}`,
clone: () => p,
json: () => Promise.resolve(response),
};
return Promise.resolve(p);
}
function mockOperation({ operationName = OPERATION_NAME, variables = { id: 3 } } = {}) {
return { operationName, variables, setContext: () => {} };
}
const setupLink = () => {
startupLink = new StartupJSLink();
link = ApolloLink.from([startupLink, new ApolloLink(() => Observable.of(FORWARDED_RESPONSE))]);
};
it('forwards requests if no calls are set up', done => {
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls).toBe(null);
expect(startupLink.request).toEqual(StartupJSLink.noopRequest);
done();
});
});
it('forwards requests if the operation is not pre-loaded', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: 3 },
},
],
};
setupLink();
link.request(mockOperation({ operationName: 'notLoaded' })).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(1);
done();
});
});
describe('variable match errors: ', () => {
it('forwards requests if the variables are not matching', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: 'NOT_MATCHING' },
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('forwards requests if more variables are set in the operation', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('forwards requests if less variables are set in the operation', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: 3, name: 'tanuki' },
},
],
};
setupLink();
link.request(mockOperation({ variables: { id: 3 } })).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('forwards requests if different variables are set', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { name: 'tanuki' },
},
],
};
setupLink();
link.request(mockOperation({ variables: { id: 3 } })).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('forwards requests if array variables have a different order', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: [3, 4] },
},
],
};
setupLink();
link.request(mockOperation({ variables: { id: [4, 3] } })).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
});
describe('error handling', () => {
it('forwards the call if the fetchCall is failing with a HTTP Error', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(404),
query: STARTUP_JS_QUERY,
variables: { id: 3 },
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('forwards the call if it errors (e.g. failing JSON)', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: Promise.reject(new Error('Parsing failed')),
query: STARTUP_JS_QUERY,
variables: { id: 3 },
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('forwards the call if the response contains an error', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(200, ERROR_RESPONSE),
query: STARTUP_JS_QUERY,
variables: { id: 3 },
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it("forwards the call if the response doesn't contain a data object", done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(200, { 'no-data': 'yay' }),
query: STARTUP_JS_QUERY,
variables: { id: 3 },
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
});
it('resolves the request if the operation is matching', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: 3 },
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(STARTUP_JS_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('resolves the request exactly once', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: 3 },
},
],
};
setupLink();
link.request(mockOperation()).subscribe(result => {
expect(result).toEqual(STARTUP_JS_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
link.request(mockOperation()).subscribe(result2 => {
expect(result2).toEqual(FORWARDED_RESPONSE);
done();
});
});
});
it('resolves the request if the variables have a different order', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: 3, name: 'foo' },
},
],
};
setupLink();
link.request(mockOperation({ variables: { name: 'foo', id: 3 } })).subscribe(result => {
expect(result).toEqual(STARTUP_JS_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('resolves the request if the variables have undefined values', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { name: 'foo' },
},
],
};
setupLink();
link
.request(mockOperation({ variables: { name: 'foo', undef: undefined } }))
.subscribe(result => {
expect(result).toEqual(STARTUP_JS_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('resolves the request if the variables are of an array format', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: [3, 4] },
},
],
};
setupLink();
link.request(mockOperation({ variables: { id: [3, 4] } })).subscribe(result => {
expect(result).toEqual(STARTUP_JS_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
it('resolves multiple requests correctly', done => {
window.gl = {
startup_graphql_calls: [
{
fetchCall: mockFetchCall(),
query: STARTUP_JS_QUERY,
variables: { id: 3 },
},
{
fetchCall: mockFetchCall(200, STARTUP_JS_RESPONSE_TWO),
query: STARTUP_JS_QUERY_TWO,
variables: { id: 3 },
},
],
};
setupLink();
link.request(mockOperation({ operationName: OPERATION_NAME_TWO })).subscribe(result => {
expect(result).toEqual(STARTUP_JS_RESPONSE_TWO);
expect(startupLink.startupCalls.size).toBe(1);
link.request(mockOperation({ operationName: OPERATION_NAME })).subscribe(result2 => {
expect(result2).toEqual(STARTUP_JS_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
done();
});
});
});
});

View File

@ -3,11 +3,30 @@
require 'spec_helper'
RSpec.describe Types::Ci::DetailedStatusType do
include GraphqlHelpers
specify { expect(described_class.graphql_name).to eq('DetailedStatus') }
it "has all fields" do
it 'has all fields' do
expect(described_class).to have_graphql_fields(:group, :icon, :favicon,
:details_path, :has_details,
:label, :text, :tooltip, :action)
end
describe 'action field' do
it 'correctly renders the field' do
stage = create(:ci_stage_entity, status: :skipped)
status = stage.detailed_status(stage.pipeline.user)
expected_status = {
button_title: status.action_button_title,
icon: status.action_icon,
method: status.action_method,
path: status.action_path,
title: status.action_title
}
expect(resolve_field('action', status)).to eq(expected_status)
end
end
end

View File

@ -50,6 +50,24 @@ RSpec.describe SortingHelper do
end
end
describe '#search_sort_direction_button' do
before do
set_sorting_url 'test_label'
end
it 'keeps label filter param' do
expect(search_sort_direction_button('created_asc')).to include('label_name=test_label')
end
it 'returns icon with sort-lowest when sort is asc' do
expect(search_sort_direction_button('created_asc')).to include('sort-lowest')
end
it 'returns icon with sort-highest when sort is desc' do
expect(search_sort_direction_button('created_desc')).to include('sort-highest')
end
end
def stub_controller_path(value)
allow(helper.controller).to receive(:controller_path).and_return(value)
end

View File

@ -183,6 +183,25 @@ RSpec.describe Emails::ServiceDesk do
it_behaves_like 'handle template content', 'new_note'
end
context 'with upload link in the note' do
let_it_be(:upload_path) { '/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg' }
let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: "a new comment with [file](#{upload_path})") }
let(:template_content) { 'some text %{ NOTE_TEXT }' }
let(:expected_body) { %Q(some text a new comment with <a href="#{project.web_url}#{upload_path}" data-link="true" class="gfm">file</a>) }
it_behaves_like 'handle template content', 'new_note'
end
context 'with all-user reference in a an external author comment' do
let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: "Hey @all, just a ping", author: User.support_bot) }
let(:template_content) { 'some text %{ NOTE_TEXT }' }
let(:expected_body) { 'Hey , just a ping' }
it_behaves_like 'handle template content', 'new_note'
end
end
end
end

View File

@ -112,6 +112,16 @@ module GraphqlHelpers
end
end
def resolve_field(name, object, args = {})
context = double("Context",
schema: GitlabSchema,
query: GraphQL::Query.new(GitlabSchema),
parent: nil)
field = described_class.fields[name]
instance = described_class.authorized_new(object, context)
field.resolve_field(instance, {}, context)
end
# Recursively convert a Hash with Ruby-style keys to GraphQL fieldname-style keys
#
# prepare_input_for_mutation({ 'my_key' => 1 })

View File

@ -2,7 +2,7 @@
RSpec.shared_examples 'search results sorted' do
context 'sort: newest' do
let(:sort) { 'newest' }
let(:sort) { 'created_desc' }
it 'sorts results by created_at' do
expect(results.objects(scope).map(&:id)).to eq([new_result.id, old_result.id, very_old_result.id])
@ -10,7 +10,7 @@ RSpec.shared_examples 'search results sorted' do
end
context 'sort: oldest' do
let(:sort) { 'oldest' }
let(:sort) { 'created_asc' }
it 'sorts results by created_at' do
expect(results.objects(scope).map(&:id)).to eq([very_old_result.id, old_result.id, new_result.id])