Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-03-15 09:09:11 +00:00
parent 458af51866
commit c8b7a349bc
40 changed files with 1149 additions and 178 deletions

View file

@ -1 +1 @@
ac2235fe44c106e9f69b6614ecb72b67421fd402
2518b0d69dfabb6b0200e54481ed3a396fc5c9f5

View file

@ -1 +1 @@
1.35.0
1.36.0

View file

@ -1,6 +1,6 @@
import { deprecatedCreateFlash as flash } from '~/flash';
import { spriteIcon } from '~/lib/utils/common_utils';
import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
import { s__, sprintf } from '~/locale';
import { s__ } from '~/locale';
// Renders math using KaTeX in any element with the
// `js-render-math` class
@ -13,30 +13,10 @@ import { s__, sprintf } from '~/locale';
const MAX_MATH_CHARS = 1000;
const MAX_RENDER_TIME_MS = 2000;
// These messages might be used with inline errors in the future. Keep them around. For now, we will
// display a single error message using flash().
// const CHAR_LIMIT_EXCEEDED_MSG = sprintf(
// s__(
// 'math|The following math is too long. For performance reasons, math blocks are limited to %{maxChars} characters. Try splitting up this block, or include an image instead.',
// ),
// { maxChars: MAX_MATH_CHARS },
// );
// const RENDER_TIME_EXCEEDED_MSG = s__(
// "math|The math in this entry is taking too long to render. Any math below this point won't be shown. Consider splitting it among multiple entries.",
// );
const RENDER_FLASH_MSG = sprintf(
s__(
'math|The math in this entry is taking too long to render and may not be displayed as expected. For performance reasons, math blocks are also limited to %{maxChars} characters. Consider splitting up large formulae, splitting math blocks among multiple entries, or using an image instead.',
),
{ maxChars: MAX_MATH_CHARS },
);
// Wait for the browser to reflow the layout. Reflowing SVG takes time.
// This has to wrap the inner function, otherwise IE/Edge throw "invalid calling object".
const waitForReflow = (fn) => {
window.requestAnimationFrame(fn);
window.requestIdleCallback(fn);
};
/**
@ -67,37 +47,69 @@ class SafeMathRenderer {
this.renderElement = this.renderElement.bind(this);
this.render = this.render.bind(this);
this.attachEvents = this.attachEvents.bind(this);
}
renderElement() {
if (!this.queue.length) {
renderElement(chosenEl) {
if (!this.queue.length && !chosenEl) {
return;
}
const el = this.queue.shift();
const el = chosenEl || this.queue.shift();
const forceRender = Boolean(chosenEl);
const text = el.textContent;
el.removeAttribute('style');
if (this.totalMS >= MAX_RENDER_TIME_MS || text.length > MAX_MATH_CHARS) {
if (!this.flashShown) {
flash(RENDER_FLASH_MSG);
this.flashShown = true;
}
if (!forceRender && (this.totalMS >= MAX_RENDER_TIME_MS || text.length > MAX_MATH_CHARS)) {
// Show unrendered math code
const wrapperElement = document.createElement('div');
const codeElement = document.createElement('pre');
codeElement.className = 'code';
codeElement.textContent = el.textContent;
el.parentNode.replaceChild(codeElement, el);
const { parentNode } = el;
parentNode.replaceChild(wrapperElement, el);
const html = `
<div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-math-container js-lazy-render-math-container fade show" role="alert">
${spriteIcon('warning', 'text-warning-600 s16 gl-alert-icon')}
<div class="display-flex gl-alert-content">
<div>${s__(
'math|Displaying this math block may cause performance issues on this page',
)}</div>
<div class="gl-alert-actions">
<button class="js-lazy-render-math btn gl-alert-action btn-primary btn-md gl-button">Display anyway</button>
</div>
</div>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
${spriteIcon('close', 's16')}
</button>
</div>
`;
if (!wrapperElement.classList.contains('lazy-alert-shown')) {
wrapperElement.innerHTML = html;
wrapperElement.append(codeElement);
wrapperElement.classList.add('lazy-alert-shown');
}
// Render the next math
this.renderElement();
} else {
this.startTime = Date.now();
/* Get the correct reference to the display container when:
* a.) Happy path: when the math block is present, and
* b.) When we've replace the block with <pre> for lazy rendering
*/
let displayContainer = el;
if (el.tagName === 'PRE') {
displayContainer = el.parentElement;
}
try {
el.innerHTML = this.katex.renderToString(text, {
displayContainer.innerHTML = this.katex.renderToString(text, {
displayMode: el.getAttribute('data-math-style') === 'display',
throwOnError: true,
maxSize: 20,
@ -135,6 +147,22 @@ class SafeMathRenderer {
// and less prone to timeouts.
setTimeout(this.renderElement, 400);
}
attachEvents() {
document.body.addEventListener('click', (event) => {
if (!event.target.classList.contains('js-lazy-render-math')) {
return;
}
const parent = event.target.closest('.js-lazy-render-math-container');
const pre = parent.nextElementSibling;
parent.remove();
this.renderElement(pre);
});
}
}
export default function renderMath($els) {
@ -146,6 +174,7 @@ export default function renderMath($els) {
.then(([katex]) => {
const renderer = new SafeMathRenderer($els.get(), katex);
renderer.render();
renderer.attachEvents();
})
.catch(() => {});
}

View file

@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import Draggable from 'vuedraggable';
import { mapActions, mapState } from 'vuex';
import { mapActions, mapGetters, mapState } from 'vuex';
import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options';
import { sprintf, __ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config';
@ -15,6 +15,7 @@ export default {
loading: __('Loading'),
loadingMoreboardItems: __('Loading more'),
showingAllIssues: __('Showing all issues'),
showingAllEpics: __('Showing all epics'),
},
components: {
BoardCard,
@ -49,14 +50,19 @@ export default {
},
computed: {
...mapState(['pageInfoByListId', 'listsFlags']),
...mapGetters(['isEpicBoard']),
listItemsCount() {
return this.isEpicBoard ? this.list.epicsCount : this.list.issuesCount;
},
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), {
pageSize: this.boardItems.length,
total: this.list.issuesCount,
total: this.listItemsCount,
issuableType: this.isEpicBoard ? 'epics' : 'issues',
});
},
boardItemsSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.list.issuesCount > this.list.maxIssueCount;
return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount;
},
hasNextPage() {
return this.pageInfoByListId[this.list.id].hasNextPage;
@ -71,8 +77,13 @@ export default {
// When list is draggable, the reference to the list needs to be accessed differently
return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
},
showingAllIssues() {
return this.boardItems.length === this.list.issuesCount;
showingAllItems() {
return this.boardItems.length === this.listItemsCount;
},
showingAllItemsText() {
return this.isEpicBoard
? this.$options.i18n.showingAllEpics
: this.$options.i18n.showingAllIssues;
},
treeRootWrapper() {
return this.canAdminList ? Draggable : 'ul';
@ -235,7 +246,7 @@ export default {
:label="$options.i18n.loadingMoreboardItems"
data-testid="count-loading-icon"
/>
<span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span>
<span v-if="showingAllItems">{{ showingAllItemsText }}</span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
</component>

View file

@ -17,7 +17,7 @@ import sidebarEventHub from '~/sidebar/event_hub';
import AccessorUtilities from '../../lib/utils/accessor';
import { inactiveId, LIST, ListType } from '../constants';
import eventHub from '../eventhub';
import IssueCount from './issue_count.vue';
import ItemCount from './item_count.vue';
export default {
i18n: {
@ -33,7 +33,7 @@ export default {
GlTooltip,
GlIcon,
GlSprintf,
IssueCount,
ItemCount,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -337,7 +337,7 @@ export default {
<gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" />
<span ref="itemCount" class="issue-count-badge-count">
<gl-icon class="gl-mr-2" :name="countIcon" />
<issue-count :issues-size="itemsCount" :max-issue-count="list.maxIssueCount" />
<item-count :items-size="itemsCount" :max-issue-count="list.maxIssueCount" />
</span>
<!-- EE start -->
<template v-if="weightFeatureAvailable && !isEpicBoard">

View file

@ -17,7 +17,7 @@ import AccessorUtilities from '../../lib/utils/accessor';
import { inactiveId, LIST, ListType } from '../constants';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
import IssueCount from './issue_count.vue';
import IssueCount from './item_count.vue';
// This component is being replaced in favor of './board_list_header.vue' for GraphQL boards
@ -308,7 +308,7 @@ export default {
<gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" />
<span ref="issueCount" class="issue-count-badge-count">
<gl-icon class="gl-mr-2" name="issues" />
<issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" />
<issue-count :items-size="issuesCount" :max-issue-count="list.maxIssueCount" />
</span>
<!-- The following is only true in EE. -->
<template v-if="weightFeatureAvailable">

View file

@ -7,7 +7,7 @@ export default {
required: false,
default: 0,
},
issuesSize: {
itemsSize: {
type: Number,
required: false,
default: 0,
@ -18,16 +18,16 @@ export default {
return this.maxIssueCount !== 0;
},
issuesExceedMax() {
return this.isMaxLimitSet && this.issuesSize > this.maxIssueCount;
return this.isMaxLimitSet && this.itemsSize > this.maxIssueCount;
},
},
};
</script>
<template>
<div class="issue-count text-nowrap">
<span class="js-issue-size" :class="{ 'text-danger': issuesExceedMax }">
{{ issuesSize }}
<div class="item-count text-nowrap">
<span :class="{ 'text-danger': issuesExceedMax }" data-testid="board-items-count">
{{ itemsSize }}
</span>
<span v-if="isMaxLimitSet" class="js-max-issue-size">
{{ maxIssueCount }}

View file

@ -5,7 +5,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import { __, n__, sprintf } from '~/locale';
import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@ -86,8 +86,26 @@ export default {
}
return {};
},
taskStatus() {
const { completedCount, count } = this.issuable.taskCompletionStatus || {};
if (!count) {
return undefined;
}
return sprintf(
n__(
'%{completedCount} of %{count} task completed',
'%{completedCount} of %{count} tasks completed',
count,
),
{ completedCount, count },
);
},
notesCount() {
return this.issuable.userDiscussionsCount ?? this.issuable.userNotesCount;
},
showDiscussions() {
return typeof this.issuable.userDiscussionsCount === 'number';
return typeof this.notesCount === 'number';
},
showIssuableMeta() {
return Boolean(
@ -148,19 +166,27 @@ export default {
v-gl-tooltip
name="eye-slash"
:title="__('Confidential')"
:aria-label="__('Confidential')"
/>
<gl-link :href="webUrl" v-bind="issuableTitleProps"
>{{ issuable.title
}}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2"
/></gl-link>
</span>
<span
v-if="taskStatus"
class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3"
data-testid="task-status"
>
{{ taskStatus }}
</span>
</div>
<div class="issuable-info">
<slot v-if="hasSlotContents('reference')" name="reference"></slot>
<span v-else data-testid="issuable-reference" class="issuable-reference"
>{{ issuableSymbol }}{{ issuable.iid }}</span
>
<span class="issuable-authored d-none d-sm-inline-block">
<span class="issuable-authored gl-display-none gl-sm-display-inline-block! gl-mr-3">
&middot;
<span
v-gl-tooltip:tooltipcontainer.bottom
@ -203,6 +229,16 @@ export default {
<li v-if="hasSlotContents('status')" class="issuable-status">
<slot name="status"></slot>
</li>
<li v-if="assignees.length" class="gl-display-flex">
<issuable-assignees
:assignees="assignees"
:icon-size="16"
:max-visible="4"
img-css-classes="gl-mr-2!"
class="gl-align-items-center gl-display-flex gl-ml-3"
/>
</li>
<slot name="statistics"></slot>
<li
v-if="showDiscussions"
data-testid="issuable-discussions"
@ -212,26 +248,17 @@ export default {
v-gl-tooltip:tooltipcontainer.top
:title="__('Comments')"
:href="issuableNotesLink"
:class="{ 'no-comments': !issuable.userDiscussionsCount }"
:class="{ 'no-comments': !notesCount }"
class="gl-reset-color!"
>
<gl-icon name="comments" />
{{ issuable.userDiscussionsCount }}
{{ notesCount }}
</gl-link>
</li>
<li v-if="assignees.length" class="gl-display-flex">
<issuable-assignees
:assignees="issuable.assignees"
:icon-size="16"
:max-visible="4"
img-css-classes="gl-mr-2!"
class="gl-align-items-center gl-display-flex gl-ml-3"
/>
</li>
</ul>
<div
data-testid="issuable-updated-at"
class="float-right issuable-updated-at d-none d-sm-inline-block"
class="float-right issuable-updated-at gl-display-none gl-sm-display-inline-block"
>
<span
v-gl-tooltip:tooltipcontainer.bottom

View file

@ -280,6 +280,9 @@ export default {
<template #status>
<slot name="status" :issuable="issuable"></slot>
</template>
<template #statistics>
<slot name="statistics" :issuable="issuable"></slot>
</template>
</issuable-item>
</ul>
<slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot>

View file

@ -0,0 +1,124 @@
<script>
import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import {
dateInWords,
getTimeRemainingInWords,
isInFuture,
isInPast,
isToday,
} from '~/lib/utils/datetime_utility';
import { convertToCamelCase } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
export default {
components: {
GlLink,
GlIcon,
IssueHealthStatus: () =>
import('ee_component/related_items_tree/components/issue_health_status.vue'),
WeightCount: () => import('ee_component/issues/components/weight_count.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
hasIssuableHealthStatusFeature: {
default: false,
},
},
props: {
issue: {
type: Object,
required: true,
},
},
computed: {
milestoneDate() {
if (this.issue.milestone?.dueDate) {
const { dueDate, startDate } = this.issue.milestone;
const date = dateInWords(new Date(dueDate), true);
const remainingTime = this.milestoneRemainingTime(dueDate, startDate);
return `${date} (${remainingTime})`;
}
return __('Milestone');
},
dueDate() {
return this.issue.dueDate && dateInWords(new Date(this.issue.dueDate), true);
},
isDueDateInPast() {
return isInPast(new Date(this.issue.dueDate));
},
timeEstimate() {
return this.issue.timeStats?.humanTimeEstimate;
},
showHealthStatus() {
return this.hasIssuableHealthStatusFeature && this.issue.healthStatus;
},
healthStatus() {
return convertToCamelCase(this.issue.healthStatus);
},
},
methods: {
milestoneRemainingTime(dueDate, startDate) {
const due = new Date(dueDate);
const start = new Date(startDate);
if (dueDate && isInPast(due)) {
return __('Past due');
} else if (dueDate && isToday(due)) {
return __('Today');
} else if (startDate && isInFuture(start)) {
return __('Upcoming');
} else if (dueDate) {
return getTimeRemainingInWords(due);
}
return '';
},
},
};
</script>
<template>
<span>
<span
v-if="issue.milestone"
class="issuable-milestone gl-display-none gl-sm-display-inline-block! gl-mr-3"
data-testid="issuable-milestone"
>
<gl-link v-gl-tooltip :href="issue.milestone.webUrl" :title="milestoneDate">
<gl-icon name="clock" />
{{ issue.milestone.title }}
</gl-link>
</span>
<span
v-if="issue.dueDate"
v-gl-tooltip
class="issuable-due-date gl-display-none gl-sm-display-inline-block! gl-mr-3"
:class="{ 'gl-text-red-500': isDueDateInPast }"
:title="__('Due date')"
data-testid="issuable-due-date"
>
<gl-icon name="calendar" />
{{ dueDate }}
</span>
<span
v-if="timeEstimate"
v-gl-tooltip
class="gl-display-none gl-sm-display-inline-block! gl-mr-3"
:title="__('Estimate')"
data-testid="time-estimate"
>
<gl-icon name="timer" />
{{ timeEstimate }}
</span>
<weight-count
class="gl-display-none gl-sm-display-inline-block gl-mr-3"
:weight="issue.weight"
/>
<issue-health-status
v-if="showHealthStatus"
class="gl-display-none gl-sm-display-inline-block"
:health-status="healthStatus"
/>
</span>
</template>

View file

@ -0,0 +1,143 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { toNumber } from 'lodash';
import createFlash from '~/flash';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableStatus } from '~/issue_show/constants';
import { PAGE_SIZE } from '~/issues_list/constants';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import IssueCardTimeInfo from './issue_card_time_info.vue';
export default {
PAGE_SIZE,
components: {
GlIcon,
IssuableList,
IssueCardTimeInfo,
BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
endpoint: {
default: '',
},
fullPath: {
default: '',
},
},
data() {
return {
currentPage: toNumber(getParameterByName('page')) || 1,
isLoading: false,
issues: [],
totalIssues: 0,
};
},
computed: {
urlParams() {
return {
page: this.currentPage,
state: IssuableStatus.Open,
};
},
},
mounted() {
this.fetchIssues();
},
methods: {
fetchIssues(pageToFetch) {
this.isLoading = true;
return axios
.get(this.endpoint, {
params: {
page: pageToFetch || this.currentPage,
per_page: this.$options.PAGE_SIZE,
state: IssuableStatus.Open,
with_labels_details: true,
},
})
.then(({ data, headers }) => {
this.currentPage = Number(headers['x-page']);
this.totalIssues = Number(headers['x-total']);
this.issues = data.map((issue) => convertObjectPropsToCamelCase(issue, { deep: true }));
})
.catch(() => {
createFlash({ message: __('An error occurred while loading issues') });
})
.finally(() => {
this.isLoading = false;
});
},
handlePageChange(page) {
this.fetchIssues(page);
},
},
};
</script>
<template>
<issuable-list
:namespace="fullPath"
recent-searches-storage-key="issues"
:search-input-placeholder="__('Search or filter results…')"
:search-tokens="[]"
:sort-options="[]"
:issuables="issues"
:tabs="[]"
current-tab=""
:issuables-loading="isLoading"
:show-pagination-controls="true"
:total-items="totalIssues"
:current-page="currentPage"
:previous-page="currentPage - 1"
:next-page="currentPage + 1"
:url-params="urlParams"
@page-change="handlePageChange"
>
<template #timeframe="{ issuable = {} }">
<issue-card-time-info :issue="issuable" />
</template>
<template #statistics="{ issuable = {} }">
<li
v-if="issuable.mergeRequestsCount"
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="__('Related merge requests')"
data-testid="issuable-mr"
>
<gl-icon name="merge-request" />
{{ issuable.mergeRequestsCount }}
</li>
<li
v-if="issuable.upvotes"
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="__('Upvotes')"
data-testid="issuable-upvotes"
>
<gl-icon name="thumb-up" />
{{ issuable.upvotes }}
</li>
<li
v-if="issuable.downvotes"
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="__('Downvotes')"
data-testid="issuable-downvotes"
>
<gl-icon name="thumb-down" />
{{ issuable.downvotes }}
</li>
<blocking-issues-count
class="gl-display-none gl-sm-display-block"
:blocking-issues-count="issuable.blockingIssuesCount"
:is-list-item="true"
/>
</template>
</issuable-list>
</template>

View file

@ -1,7 +1,8 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import IssuablesListApp from './components/issuables_list_app.vue';
import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue';
@ -64,6 +65,37 @@ function mountIssuablesListApp() {
});
}
export function initIssuesListApp() {
const el = document.querySelector('.js-issues-list');
if (!el) {
return false;
}
const {
endpoint,
fullPath,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
} = el.dataset;
return new Vue({
el,
// Currently does not use Vue Apollo, but need to provide {} for now until the
// issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153
apolloProvider: {},
provide: {
endpoint,
fullPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
},
render: (createComponent) => createComponent(IssuesListApp),
});
}
export default function initIssuablesList() {
mountJiraIssuesListApp();
mountIssuablesListApp();

View file

@ -768,6 +768,19 @@ export const nMonthsAfter = (date, numberOfMonths, { utc = false } = {}) => {
return new Date(cloneValue);
};
/**
* Returns the date `n` years after the date provided.
*
* @param {Date} date the initial date
* @param {Number} numberOfYears number of years after
* @return {Date} A `Date` object `n` years after the provided `Date`
*/
export const nYearsAfter = (date, numberOfYears) => {
const clone = newDate(date);
clone.setFullYear(clone.getFullYear() + numberOfYears);
return clone;
};
/**
* Returns the date `n` months before the date provided
*
@ -992,6 +1005,78 @@ export const isToday = (date) => {
);
};
/**
* Checks whether the date is in the past.
*
* @param {Date} date
* @return {Boolean} Returns true if the date falls before today, otherwise false.
*/
export const isInPast = (date) => !isToday(date) && differenceInMilliseconds(date, Date.now()) > 0;
/**
* Checks whether the date is in the future.
* .
* @param {Date} date
* @return {Boolean} Returns true if the date falls after today, otherwise false.
*/
export const isInFuture = (date) =>
!isToday(date) && differenceInMilliseconds(Date.now(), date) > 0;
/**
* Checks whether dateA falls before dateB.
*
* @param {Date} dateA
* @param {Date} dateB
* @return {Boolean} Returns true if dateA falls before dateB, otherwise false
*/
export const fallsBefore = (dateA, dateB) => differenceInMilliseconds(dateA, dateB) > 0;
/**
* Removes the time component of the date.
*
* @param {Date} date
* @return {Date} Returns a clone of the date with the time set to midnight
*/
export const removeTime = (date) => {
const clone = newDate(date);
clone.setHours(0, 0, 0, 0);
return clone;
};
/**
* Calculates the time remaining from today in words in the format
* `n days/weeks/months/years remaining`.
*
* @param {Date} date A date in future
* @return {String} The time remaining in the format `n days/weeks/months/years remaining`
*/
export const getTimeRemainingInWords = (date) => {
const today = removeTime(new Date());
const dateInFuture = removeTime(date);
const oneWeekFromNow = nWeeksAfter(today, 1);
const oneMonthFromNow = nMonthsAfter(today, 1);
const oneYearFromNow = nYearsAfter(today, 1);
if (fallsBefore(dateInFuture, oneWeekFromNow)) {
const days = getDayDifference(today, dateInFuture);
return n__('1 day remaining', '%d days remaining', days);
}
if (fallsBefore(dateInFuture, oneMonthFromNow)) {
const weeks = Math.floor(getDayDifference(today, dateInFuture) / 7);
return n__('1 week remaining', '%d weeks remaining', weeks);
}
if (fallsBefore(dateInFuture, oneYearFromNow)) {
const months = differenceInMonths(today, dateInFuture);
return n__('1 month remaining', '%d months remaining', months);
}
const years = dateInFuture.getFullYear() - today.getFullYear();
return n__('1 year remaining', '%d years remaining', years);
};
/**
* Returns the start of the provided day
*

View file

@ -5,7 +5,7 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons';
import initIssuableByEmail from '~/issuable/init_issuable_by_email';
import IssuableIndex from '~/issuable_index';
import initIssuablesList from '~/issues_list';
import initIssuablesList, { initIssuesListApp } from '~/issues_list';
import initManualOrdering from '~/manual_ordering';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
@ -28,3 +28,4 @@ initManualOrdering();
initIssuablesList();
initIssuableByEmail();
initCsvImportExportButtons();
initIssuesListApp();

View file

@ -360,31 +360,29 @@ module SearchHelper
end
end
# Sanitize a HTML field for search display. Most tags are stripped out and the
# maximum length is set to 200 characters.
def search_md_sanitize(source)
source = Truncato.truncate(
source,
count_tags: false,
count_tail: false,
max_length: 200
)
html = markdown(source)
# Truncato's filtered_tags and filtered_attributes are not quite the same
sanitize(html, tags: %w(a p ol ul li pre code))
search_sanitize(markdown(search_truncate(source)))
end
def simple_search_highlight_and_truncate(text, phrase, options = {})
text = Truncato.truncate(
text,
highlight(search_sanitize(search_truncate(text)), phrase.split, options)
end
# Sanitize a HTML field for search display. Most tags are stripped out and the
# maximum length is set to 200 characters.
def search_truncate(source)
Truncato.truncate(
source,
count_tags: false,
count_tail: false,
max_length: options.delete(:length) { 200 }
filtered_tags: %w(img),
max_length: 200
)
end
highlight(text, phrase.split, options)
def search_sanitize(html)
# Truncato's filtered_tags and filtered_attributes are not quite the same
sanitize(html, tags: %w(a p ol ul li pre code))
end
# _search_highlight is used in EE override

View file

@ -17,16 +17,25 @@
.top-area
= render 'shared/issuable/nav', type: :issues
= render "projects/issues/nav_btns"
= render 'shared/issuable/search_bar', type: :issues
- if @can_bulk_update
= render 'shared/issuable/bulk_update_sidebar', type: :issues
- if Feature.enabled?(:vue_issues_list, @project)
- data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id)))
.js-issues-list{ data: { endpoint: data_endpoint,
full_path: @project.full_path,
has_blocked_issues_feature: Gitlab.ee? && @project.feature_available?(:blocked_issues).to_s,
has_issuable_health_status_feature: Gitlab.ee? && @project.feature_available?(:issuable_health_status).to_s,
has_issue_weights_feature: Gitlab.ee? && @project.feature_available?(:issue_weights).to_s } }
- else
= render 'shared/issuable/search_bar', type: :issues
.issues-holder
= render 'issues'
- if new_issue_email
.issuable-footer.text-center
.js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
- if @can_bulk_update
= render 'shared/issuable/bulk_update_sidebar', type: :issues
.issues-holder
= render 'issues'
- if new_issue_email
.issuable-footer.text-center
.js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
- else
- new_project_issue_button_path = @project.archived? ? false : new_project_issue_path(@project)
= render 'shared/empty_states/issues', new_project_issue_button_path: new_project_issue_button_path, show_import_button: true

View file

@ -1,6 +1,8 @@
- if @teams_error_message
= content_for :flash_message do
.alert.alert-danger= @teams_error_message
.gl-alert.gl-alert-danger
= sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
.gl-alert-body= @teams_error_message
%p
You arent a member of any team on the Mattermost instance at

View file

@ -0,0 +1,5 @@
---
title: Convert mattermost alert to pajamas
merge_request: 56556
author:
type: other

View file

@ -0,0 +1,5 @@
---
title: Fix DOS on Math blocks
merge_request: 54898
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: Sanitize issue description in search result
merge_request: 56256
author:
type: fixed

View file

@ -0,0 +1,5 @@
---
title: Upgrade Pages to v1.36.0
merge_request: 56295
author:
type: added

View file

@ -0,0 +1,8 @@
---
name: vue_issues_list
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55699
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/323743
milestone: '13.10'
type: development
group: group::project management
default_enabled: false

View file

@ -4667,7 +4667,7 @@ An edge in a connection.
| `containerRepositories` | [`ContainerRepositoryConnection`](#containerrepositoryconnection) | Container repositories of the project. |
| `containerRepositoriesCount` | [`Int!`](#int) | Number of container repositories in the project. |
| `createdAt` | [`Time`](#time) | Timestamp of the project creation. |
| `dastProfiles` | [`DastProfileConnection`](#dastprofileconnection) | DAST Profiles associated with the project. Always returns no nodes if `dast_saved_scans` is disabled. |
| `dastProfiles` | [`DastProfileConnection`](#dastprofileconnection) | DAST Profiles associated with the project. |
| `dastScannerProfiles` | [`DastScannerProfileConnection`](#dastscannerprofileconnection) | The DAST scanner profiles associated with the project. |
| `dastSiteProfile` | [`DastSiteProfile`](#dastsiteprofile) | DAST Site Profile associated with the project. |
| `dastSiteProfiles` | [`DastSiteProfileConnection`](#dastsiteprofileconnection) | DAST Site Profiles associated with the project. |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -136,10 +136,6 @@ bar at the top of the page. Under **More**, select **Security**.
![Security Center navigation link](img/security_center_dashboard_link_v12_4.png)
The dashboard and vulnerability report are empty before you add projects.
![Uninitialized Security Center](img/security_center_dashboard_empty_v13_4.png)
### Adding projects to the Security Center
To add projects to the Security Center:

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
module Gitlab
module Ci
module Build
class Cache
include ::Gitlab::Utils::StrongMemoize
def initialize(cache, pipeline)
if multiple_cache_per_job?
cache = Array.wrap(cache)
@cache = cache.map do |cache|
Gitlab::Ci::Pipeline::Seed::Build::Cache
.new(pipeline, cache)
end
else
@cache = Gitlab::Ci::Pipeline::Seed::Build::Cache
.new(pipeline, cache)
end
end
def cache_attributes
strong_memoize(:cache_attributes) do
if multiple_cache_per_job?
if @cache.empty?
{}
else
{ options: { cache: @cache.map(&:attributes) } }
end
else
@cache.build_attributes
end
end
end
private
def multiple_cache_per_job?
strong_memoize(:multiple_cache_per_job) do
::Gitlab::Ci::Features.multiple_cache_per_job?
end
end
end
end
end
end

View file

@ -28,16 +28,8 @@ module Gitlab
.fabricate(attributes.delete(:except))
@rules = Gitlab::Ci::Build::Rules
.new(attributes.delete(:rules), default_when: 'on_success')
if multiple_cache_per_job?
cache = Array.wrap(attributes.delete(:cache))
@cache = cache.map do |cache|
Seed::Build::Cache.new(pipeline, cache)
end
else
@cache = Seed::Build::Cache
.new(pipeline, attributes.delete(:cache))
end
@cache = Gitlab::Ci::Build::Cache
.new(attributes.delete(:cache), pipeline)
end
def name
@ -69,7 +61,7 @@ module Gitlab
.deep_merge(pipeline_attributes)
.deep_merge(rules_attributes)
.deep_merge(allow_failure_criteria_attributes)
.deep_merge(cache_attributes)
.deep_merge(@cache.cache_attributes)
end
def bridge?
@ -203,26 +195,6 @@ module Gitlab
end
end
def cache_attributes
strong_memoize(:cache_attributes) do
if multiple_cache_per_job?
if @cache.empty?
{}
else
{ options: { cache: @cache.map(&:attributes) } }
end
else
@cache.build_attributes
end
end
end
def multiple_cache_per_job?
strong_memoize(:multiple_cache_per_job) do
::Gitlab::Ci::Features.multiple_cache_per_job?
end
end
# If a job uses `allow_failure:exit_codes` and `rules:allow_failure`
# we need to prevent the exit codes from being persisted because they
# would break the behavior defined by `rules:allow_failure`.

View file

@ -409,6 +409,11 @@ msgstr ""
msgid "%{completedCount} completed weight"
msgstr ""
msgid "%{completedCount} of %{count} task completed"
msgid_plural "%{completedCount} of %{count} tasks completed"
msgstr[0] ""
msgstr[1] ""
msgid "%{completedWeight} of %{totalWeight} weight completed"
msgstr ""
@ -1151,6 +1156,11 @@ msgid_plural "%d days"
msgstr[0] ""
msgstr[1] ""
msgid "1 day remaining"
msgid_plural "%d days remaining"
msgstr[0] ""
msgstr[1] ""
msgid "1 deploy key"
msgid_plural "%d deploy keys"
msgstr[0] ""
@ -1191,6 +1201,11 @@ msgid_plural "%d minutes"
msgstr[0] ""
msgstr[1] ""
msgid "1 month remaining"
msgid_plural "%d months remaining"
msgstr[0] ""
msgstr[1] ""
msgid "1 open issue"
msgid_plural "%{issues} open issues"
msgstr[0] ""
@ -1216,6 +1231,16 @@ msgid_plural "%{num} users"
msgstr[0] ""
msgstr[1] ""
msgid "1 week remaining"
msgid_plural "%d weeks remaining"
msgstr[0] ""
msgstr[1] ""
msgid "1 year remaining"
msgid_plural "%d years remaining"
msgstr[0] ""
msgstr[1] ""
msgid "1-9 contributions"
msgstr ""
@ -1935,6 +1960,9 @@ msgstr ""
msgid "Add previously merged commits"
msgstr ""
msgid "Add projects"
msgstr ""
msgid "Add reaction"
msgstr ""
@ -21284,9 +21312,6 @@ msgstr ""
msgid "OnDemandScans|Manage DAST scans"
msgstr ""
msgid "OnDemandScans|Manage profiles"
msgstr ""
msgid "OnDemandScans|Manage scanner profiles"
msgstr ""
@ -21311,9 +21336,6 @@ msgstr ""
msgid "OnDemandScans|On-demand scans run outside the DevOps cycle and find vulnerabilities in your projects. %{learnMoreLinkStart}Learn more%{learnMoreLinkEnd}"
msgstr ""
msgid "OnDemandScans|Run scan"
msgstr ""
msgid "OnDemandScans|Save and run scan"
msgstr ""
@ -26839,19 +26861,16 @@ msgstr ""
msgid "SecurityReports|%{firstProject}, %{secondProject}, and %{rest}"
msgstr ""
msgid "SecurityReports|Add a project to your dashboard"
msgstr ""
msgid "SecurityReports|Add or remove projects to monitor in the security area. Projects included in this list will have their results displayed in the security dashboard and vulnerability report."
msgstr ""
msgid "SecurityReports|Add projects"
msgstr ""
msgid "SecurityReports|Add projects to your group"
msgid "SecurityReports|All"
msgstr ""
msgid "SecurityReports|All"
msgid "SecurityReports|Although it's rare to have no vulnerabilities, it can happen. Check your settings to make sure you've set up your dashboard correctly."
msgstr ""
msgid "SecurityReports|Change status"
@ -26866,6 +26885,9 @@ msgstr ""
msgid "SecurityReports|Comment edited on '%{vulnerabilityName}'"
msgstr ""
msgid "SecurityReports|Configure security testing"
msgstr ""
msgid "SecurityReports|Create Jira issue"
msgstr ""
@ -26923,7 +26945,22 @@ msgstr ""
msgid "SecurityReports|Learn more about setting up your dashboard"
msgstr ""
msgid "SecurityReports|Monitor vulnerabilities in your code"
msgid "SecurityReports|Manage and track vulnerabilities identified in projects within your group. Vulnerabilities in projects are shown here when security testing is configured."
msgstr ""
msgid "SecurityReports|Manage and track vulnerabilities identified in your project. Vulnerabilities are shown here when security testing is configured."
msgstr ""
msgid "SecurityReports|Manage and track vulnerabilities identified in your selected projects. Vulnerabilities for selected projects with security testing configured are shown here."
msgstr ""
msgid "SecurityReports|Monitor vulnerabilities in all of your projects"
msgstr ""
msgid "SecurityReports|Monitor vulnerabilities in your group"
msgstr ""
msgid "SecurityReports|Monitor vulnerabilities in your project"
msgstr ""
msgid "SecurityReports|Monitored projects"
@ -26932,9 +26969,6 @@ msgstr ""
msgid "SecurityReports|More info"
msgstr ""
msgid "SecurityReports|More information"
msgstr ""
msgid "SecurityReports|No activity"
msgstr ""
@ -26998,15 +27032,6 @@ msgstr ""
msgid "SecurityReports|Status"
msgstr ""
msgid "SecurityReports|The security dashboard displays the latest security findings for projects you wish to monitor. Add projects to your group to view their vulnerabilities here."
msgstr ""
msgid "SecurityReports|The security dashboard displays the latest security findings for projects you wish to monitor. Select \"Edit dashboard\" to add and remove projects."
msgstr ""
msgid "SecurityReports|The security dashboard displays the latest security report. Use it to find and fix vulnerabilities."
msgstr ""
msgid "SecurityReports|There was an error adding the comment."
msgstr ""
@ -27058,9 +27083,6 @@ msgstr ""
msgid "SecurityReports|While it's rare to have no vulnerabilities for your pipeline, it can happen. In any event, we ask that you double check your settings to make sure all security scanning jobs have passed successfully."
msgstr ""
msgid "SecurityReports|While it's rare to have no vulnerabilities, it can happen. In any event, we ask that you double check your settings to make sure you've set up your dashboard correctly."
msgstr ""
msgid "SecurityReports|With issues"
msgstr ""
@ -27845,9 +27867,15 @@ msgstr[1] ""
msgid "Showing %{limit} of %{total_count} issues. "
msgstr ""
msgid "Showing %{pageSize} of %{total} %{issuableType}"
msgstr ""
msgid "Showing %{pageSize} of %{total} issues"
msgstr ""
msgid "Showing all epics"
msgstr ""
msgid "Showing all issues"
msgstr ""
@ -35863,7 +35891,7 @@ msgstr ""
msgid "manual"
msgstr ""
msgid "math|The math in this entry is taking too long to render and may not be displayed as expected. For performance reasons, math blocks are also limited to %{maxChars} characters. Consider splitting up large formulae, splitting math blocks among multiple entries, or using an image instead."
msgid "math|Displaying this math block may cause performance issues on this page"
msgstr ""
msgid "math|There was an error rendering this math block"

View file

@ -298,7 +298,7 @@ RSpec.describe 'Issue Boards', :js do
it 'shows issue count on the list' do
page.within(find(".board:nth-child(2)")) do
expect(page.find('.js-issue-size')).to have_text(total_planning_issues)
expect(page.find('[data-testid="board-items-count"]')).to have_text(total_planning_issues)
expect(page).not_to have_selector('.js-max-issue-size')
end
end

View file

@ -39,4 +39,20 @@ RSpec.describe 'Math rendering', :js do
expect(page).to have_selector('.katex-html a', text: 'Gitlab')
end
end
it 'renders lazy load button' do
description = <<~MATH
```math
\Huge \sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}
```
MATH
issue = create(:issue, project: project, description: description)
visit project_issue_path(project, issue)
page.within '.description > .md' do
expect(page).to have_selector('.js-lazy-render-math')
end
end
end

View file

@ -113,7 +113,7 @@ RSpec.describe 'Set up Mattermost slash commands', :js do
click_link 'Add to Mattermost'
expect(page).to have_selector('.alert')
expect(page).to have_selector('.gl-alert')
expect(page).to have_content('test mattermost error message')
end

View file

@ -1,10 +1,10 @@
import { shallowMount } from '@vue/test-utils';
import IssueCount from '~/boards/components/issue_count.vue';
import IssueCount from '~/boards/components/item_count.vue';
describe('IssueCount', () => {
let vm;
let maxIssueCount;
let issuesSize;
let itemsSize;
const createComponent = (props) => {
vm = shallowMount(IssueCount, { propsData: props });
@ -12,20 +12,20 @@ describe('IssueCount', () => {
afterEach(() => {
maxIssueCount = 0;
issuesSize = 0;
itemsSize = 0;
if (vm) vm.destroy();
});
describe('when maxIssueCount is zero', () => {
beforeEach(() => {
issuesSize = 3;
itemsSize = 3;
createComponent({ maxIssueCount: 0, issuesSize });
createComponent({ maxIssueCount: 0, itemsSize });
});
it('contains issueSize in the template', () => {
expect(vm.find('.js-issue-size').text()).toEqual(String(issuesSize));
expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
});
it('does not contains maxIssueCount in the template', () => {
@ -36,9 +36,9 @@ describe('IssueCount', () => {
describe('when maxIssueCount is greater than zero', () => {
beforeEach(() => {
maxIssueCount = 2;
issuesSize = 1;
itemsSize = 1;
createComponent({ maxIssueCount, issuesSize });
createComponent({ maxIssueCount, itemsSize });
});
afterEach(() => {
@ -46,7 +46,7 @@ describe('IssueCount', () => {
});
it('contains issueSize in the template', () => {
expect(vm.find('.js-issue-size').text()).toEqual(String(issuesSize));
expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
});
it('contains maxIssueCount in the template', () => {
@ -60,10 +60,10 @@ describe('IssueCount', () => {
describe('when issueSize is greater than maxIssueCount', () => {
beforeEach(() => {
issuesSize = 3;
itemsSize = 3;
maxIssueCount = 2;
createComponent({ maxIssueCount, issuesSize });
createComponent({ maxIssueCount, itemsSize });
});
afterEach(() => {
@ -71,7 +71,7 @@ describe('IssueCount', () => {
});
it('contains issueSize in the template', () => {
expect(vm.find('.js-issue-size').text()).toEqual(String(issuesSize));
expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
});
it('contains maxIssueCount in the template', () => {
@ -79,7 +79,7 @@ describe('IssueCount', () => {
});
it('has text-danger class', () => {
expect(vm.find('.text-danger').text()).toEqual(String(issuesSize));
expect(vm.find('.text-danger').text()).toEqual(String(itemsSize));
});
});
});

View file

@ -294,7 +294,17 @@ describe('IssuableItem', () => {
expect(confidentialEl.exists()).toBe(true);
expect(confidentialEl.props('name')).toBe('eye-slash');
expect(confidentialEl.attributes('title')).toBe('Confidential');
expect(confidentialEl.attributes()).toMatchObject({
title: 'Confidential',
arialabel: 'Confidential',
});
});
it('renders task status', () => {
const taskStatus = wrapper.find('[data-testid="task-status"]');
const expected = `${mockIssuable.taskCompletionStatus.completedCount} of ${mockIssuable.taskCompletionStatus.count} tasks completed`;
expect(taskStatus.text()).toBe(expected);
});
it('renders issuable reference', () => {

View file

@ -53,6 +53,10 @@ export const mockIssuable = {
},
assignees: [mockAuthor],
userDiscussionsCount: 2,
taskCompletionStatus: {
count: 2,
completedCount: 1,
},
};
export const mockIssuables = [

View file

@ -0,0 +1,109 @@
import { GlIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { useFakeDate } from 'helpers/fake_date';
import IssueCardTimeInfo from '~/issues_list/components/issue_card_time_info.vue';
describe('IssuesListApp component', () => {
useFakeDate(2020, 11, 11);
let wrapper;
const issue = {
milestone: {
dueDate: '2020-12-17',
startDate: '2020-12-10',
title: 'My milestone',
webUrl: '/milestone/webUrl',
},
dueDate: '2020-12-12',
timeStats: {
humanTimeEstimate: '1w',
},
};
const findMilestone = () => wrapper.find('[data-testid="issuable-milestone"]');
const findMilestoneTitle = () => findMilestone().find(GlLink).attributes('title');
const findDueDate = () => wrapper.find('[data-testid="issuable-due-date"]');
const mountComponent = ({
dueDate = issue.dueDate,
milestoneDueDate = issue.milestone.dueDate,
milestoneStartDate = issue.milestone.startDate,
} = {}) =>
shallowMount(IssueCardTimeInfo, {
propsData: {
issue: {
...issue,
milestone: {
...issue.milestone,
dueDate: milestoneDueDate,
startDate: milestoneStartDate,
},
dueDate,
},
},
});
afterEach(() => {
wrapper.destroy();
});
describe('milestone', () => {
it('renders', () => {
wrapper = mountComponent();
const milestone = findMilestone();
expect(milestone.text()).toBe(issue.milestone.title);
expect(milestone.find(GlIcon).props('name')).toBe('clock');
expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webUrl);
});
describe.each`
time | text | milestoneDueDate | milestoneStartDate | expected
${'due date is in past'} | ${'Past due'} | ${'2020-09-09'} | ${null} | ${'Sep 9, 2020 (Past due)'}
${'due date is today'} | ${'Today'} | ${'2020-12-11'} | ${null} | ${'Dec 11, 2020 (Today)'}
${'start date is in future'} | ${'Upcoming'} | ${'2021-03-01'} | ${'2021-02-01'} | ${'Mar 1, 2021 (Upcoming)'}
${'due date is in future'} | ${'2 weeks remaining'} | ${'2020-12-25'} | ${null} | ${'Dec 25, 2020 (2 weeks remaining)'}
`('when $description', ({ text, milestoneDueDate, milestoneStartDate, expected }) => {
it(`renders with "${text}"`, () => {
wrapper = mountComponent({ milestoneDueDate, milestoneStartDate });
expect(findMilestoneTitle()).toBe(expected);
});
});
});
describe('due date', () => {
describe('when upcoming', () => {
it('renders', () => {
wrapper = mountComponent();
const dueDate = findDueDate();
expect(dueDate.text()).toBe('Dec 12, 2020');
expect(dueDate.attributes('title')).toBe('Due date');
expect(dueDate.find(GlIcon).props('name')).toBe('calendar');
expect(dueDate.classes()).not.toContain('gl-text-red-500');
});
});
describe('when in the past', () => {
it('renders in red', () => {
wrapper = mountComponent({ dueDate: new Date('2020-10-10') });
expect(findDueDate().classes()).toContain('gl-text-red-500');
});
});
});
it('renders time estimate', () => {
wrapper = mountComponent();
const timeEstimate = wrapper.find('[data-testid="time-estimate"]');
expect(timeEstimate.text()).toBe(issue.timeStats.humanTimeEstimate);
expect(timeEstimate.attributes('title')).toBe('Estimate');
expect(timeEstimate.find(GlIcon).props('name')).toBe('timer');
});
});

View file

@ -0,0 +1,98 @@
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import axios from '~/lib/utils/axios_utils';
describe('IssuesListApp component', () => {
let axiosMock;
let wrapper;
const fullPath = 'path/to/project';
const endpoint = 'api/endpoint';
const state = 'opened';
const xPage = 1;
const xTotal = 25;
const fetchIssuesResponse = {
data: [],
headers: {
'x-page': xPage,
'x-total': xTotal,
},
};
const findIssuableList = () => wrapper.findComponent(IssuableList);
const mountComponent = () =>
shallowMount(IssuesListApp, {
provide: {
endpoint,
fullPath,
},
});
beforeEach(async () => {
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
wrapper = mountComponent();
await waitForPromises();
});
afterEach(() => {
axiosMock.reset();
wrapper.destroy();
});
it('renders IssuableList', () => {
expect(findIssuableList().props()).toMatchObject({
namespace: fullPath,
recentSearchesStorageKey: 'issues',
searchInputPlaceholder: 'Search or filter results…',
showPaginationControls: true,
issuables: [],
totalItems: xTotal,
currentPage: xPage,
previousPage: xPage - 1,
nextPage: xPage + 1,
urlParams: { page: xPage, state },
});
});
describe('when "page-change" event is emitted', () => {
const data = [{ id: 10, title: 'title', state }];
const page = 2;
const totalItems = 21;
beforeEach(async () => {
axiosMock.onGet(endpoint).reply(200, data, {
'x-page': page,
'x-total': totalItems,
});
findIssuableList().vm.$emit('page-change', page);
await waitForPromises();
});
it('fetches issues with expected params', async () => {
expect(axiosMock.history.get[1].params).toEqual({
page,
per_page: 20,
state,
with_labels_details: true,
});
});
it('updates IssuableList with response data', () => {
expect(findIssuableList().props()).toMatchObject({
issuables: data,
totalItems,
currentPage: page,
previousPage: page - 1,
nextPage: page + 1,
urlParams: { page, state },
});
});
});
});

View file

@ -764,6 +764,21 @@ describe('date addition/subtraction methods', () => {
);
});
describe('nYearsAfter', () => {
it.each`
date | numberOfYears | expected
${'2020-07-06'} | ${1} | ${'2021-07-06'}
${'2020-07-06'} | ${15} | ${'2035-07-06'}
`(
'returns $expected for "$numberOfYears year(s) after $date"',
({ date, numberOfYears, expected }) => {
expect(datetimeUtility.nYearsAfter(new Date(date), numberOfYears)).toEqual(
new Date(expected),
);
},
);
});
describe('nMonthsBefore', () => {
// The previous month (February) has 28 days
const march2019 = '2019-03-15T00:00:00.000Z';
@ -1018,6 +1033,81 @@ describe('isToday', () => {
});
});
describe('isInPast', () => {
it.each`
date | expected
${new Date('2024-12-15')} | ${false}
${new Date('2020-07-06T00:00')} | ${false}
${new Date('2020-07-05T23:59:59.999')} | ${true}
${new Date('2020-07-05')} | ${true}
${new Date('1999-03-21')} | ${true}
`('returns $expected for $date', ({ date, expected }) => {
expect(datetimeUtility.isInPast(date)).toBe(expected);
});
});
describe('isInFuture', () => {
it.each`
date | expected
${new Date('2024-12-15')} | ${true}
${new Date('2020-07-07T00:00')} | ${true}
${new Date('2020-07-06T23:59:59.999')} | ${false}
${new Date('2020-07-06')} | ${false}
${new Date('1999-03-21')} | ${false}
`('returns $expected for $date', ({ date, expected }) => {
expect(datetimeUtility.isInFuture(date)).toBe(expected);
});
});
describe('fallsBefore', () => {
it.each`
dateA | dateB | expected
${new Date('2020-07-06T23:59:59.999')} | ${new Date('2020-07-07T00:00')} | ${true}
${new Date('2020-07-07T00:00')} | ${new Date('2020-07-06T23:59:59.999')} | ${false}
${new Date('2020-04-04')} | ${new Date('2021-10-10')} | ${true}
${new Date('2021-10-10')} | ${new Date('2020-04-04')} | ${false}
`('returns $expected for "$dateA falls before $dateB"', ({ dateA, dateB, expected }) => {
expect(datetimeUtility.fallsBefore(dateA, dateB)).toBe(expected);
});
});
describe('removeTime', () => {
it.each`
date | expected
${new Date('2020-07-07')} | ${new Date('2020-07-07T00:00:00.000')}
${new Date('2020-07-07T00:00:00.001')} | ${new Date('2020-07-07T00:00:00.000')}
${new Date('2020-07-07T23:59:59.999')} | ${new Date('2020-07-07T00:00:00.000')}
${new Date('2020-07-07T12:34:56.789')} | ${new Date('2020-07-07T00:00:00.000')}
`('returns $expected for $date', ({ date, expected }) => {
expect(datetimeUtility.removeTime(date)).toEqual(expected);
});
});
describe('getTimeRemainingInWords', () => {
it.each`
date | expected
${new Date('2020-07-06T12:34:56.789')} | ${'0 days remaining'}
${new Date('2020-07-07T12:34:56.789')} | ${'1 day remaining'}
${new Date('2020-07-08T12:34:56.789')} | ${'2 days remaining'}
${new Date('2020-07-12T12:34:56.789')} | ${'6 days remaining'}
${new Date('2020-07-13T12:34:56.789')} | ${'1 week remaining'}
${new Date('2020-07-19T12:34:56.789')} | ${'1 week remaining'}
${new Date('2020-07-20T12:34:56.789')} | ${'2 weeks remaining'}
${new Date('2020-07-27T12:34:56.789')} | ${'3 weeks remaining'}
${new Date('2020-08-03T12:34:56.789')} | ${'4 weeks remaining'}
${new Date('2020-08-05T12:34:56.789')} | ${'4 weeks remaining'}
${new Date('2020-08-06T12:34:56.789')} | ${'1 month remaining'}
${new Date('2020-09-06T12:34:56.789')} | ${'2 months remaining'}
${new Date('2021-06-06T12:34:56.789')} | ${'11 months remaining'}
${new Date('2021-07-06T12:34:56.789')} | ${'1 year remaining'}
${new Date('2022-07-06T12:34:56.789')} | ${'2 years remaining'}
${new Date('2030-07-06T12:34:56.789')} | ${'10 years remaining'}
${new Date('2119-07-06T12:34:56.789')} | ${'99 years remaining'}
`('returns $expected for $date', ({ date, expected }) => {
expect(datetimeUtility.getTimeRemainingInWords(date)).toEqual(expected);
});
});
describe('getStartOfDay', () => {
beforeEach(() => {
timezoneMock.register('US/Eastern');

View file

@ -534,10 +534,11 @@ RSpec.describe SearchHelper do
where(:description, :expected) do
'test' | '<span class="gl-text-gray-900 gl-font-weight-bold">test</span>'
'<span style="color: blue;">this test should not be blue</span>' | '<span>this <span class="gl-text-gray-900 gl-font-weight-bold">test</span> should not be blue</span>'
'<span style="color: blue;">this test should not be blue</span>' | 'this <span class="gl-text-gray-900 gl-font-weight-bold">test</span> should not be blue'
'<a href="#" onclick="alert(\'XSS\')">Click Me test</a>' | '<a href="#">Click Me <span class="gl-text-gray-900 gl-font-weight-bold">test</span></a>'
'<script type="text/javascript">alert(\'Another XSS\');</script> test' | ' <span class="gl-text-gray-900 gl-font-weight-bold">test</span>'
'Lorem test ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec.' | 'Lorem <span class="gl-text-gray-900 gl-font-weight-bold">test</span> ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Don...'
'<img src="https://random.foo.com/test.png" width="128" height="128" />some image' | 'some image'
end
with_them do

View file

@ -0,0 +1,105 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Ci::Build::Cache do
describe '.initialize' do
context 'when the multiple cache feature flag is disabled' do
before do
stub_feature_flags(multiple_cache_per_job: false)
end
it 'instantiates a cache seed' do
cache_config = { key: 'key-a' }
pipeline = double(::Ci::Pipeline)
cache_seed = double(Gitlab::Ci::Pipeline::Seed::Build::Cache)
allow(Gitlab::Ci::Pipeline::Seed::Build::Cache).to receive(:new).and_return(cache_seed)
cache = described_class.new(cache_config, pipeline)
expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, cache_config)
expect(cache.instance_variable_get(:@cache)).to eq(cache_seed)
end
end
context 'when the multiple cache feature flag is enabled' do
context 'when the cache is an array' do
it 'instantiates an array of cache seeds' do
cache_config = [{ key: 'key-a' }, { key: 'key-b' }]
pipeline = double(::Ci::Pipeline)
cache_seed_a = double(Gitlab::Ci::Pipeline::Seed::Build::Cache)
cache_seed_b = double(Gitlab::Ci::Pipeline::Seed::Build::Cache)
allow(Gitlab::Ci::Pipeline::Seed::Build::Cache).to receive(:new).and_return(cache_seed_a, cache_seed_b)
cache = described_class.new(cache_config, pipeline)
expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, { key: 'key-a' })
expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, { key: 'key-b' })
expect(cache.instance_variable_get(:@cache)).to eq([cache_seed_a, cache_seed_b])
end
end
context 'when the cache is a hash' do
it 'instantiates a cache seed' do
cache_config = { key: 'key-a' }
pipeline = double(::Ci::Pipeline)
cache_seed = double(Gitlab::Ci::Pipeline::Seed::Build::Cache)
allow(Gitlab::Ci::Pipeline::Seed::Build::Cache).to receive(:new).and_return(cache_seed)
cache = described_class.new(cache_config, pipeline)
expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, cache_config)
expect(cache.instance_variable_get(:@cache)).to eq([cache_seed])
end
end
end
end
describe '#cache_attributes' do
context 'when the multiple cache feature flag is disabled' do
before do
stub_feature_flags(multiple_cache_per_job: false)
end
it "returns the cache seed's build attributes" do
cache_config = { key: 'key-a' }
pipeline = double(::Ci::Pipeline)
cache = described_class.new(cache_config, pipeline)
attributes = cache.cache_attributes
expect(attributes).to eq({
options: { cache: { key: 'key-a' } }
})
end
end
context 'when the multiple cache feature flag is enabled' do
context 'when there are no caches' do
it 'returns an empty hash' do
cache_config = []
pipeline = double(::Ci::Pipeline)
cache = described_class.new(cache_config, pipeline)
attributes = cache.cache_attributes
expect(attributes).to eq({})
end
end
context 'when there are caches' do
it 'returns the structured attributes for the caches' do
cache_config = [{ key: 'key-a' }, { key: 'key-b' }]
pipeline = double(::Ci::Pipeline)
cache = described_class.new(cache_config, pipeline)
attributes = cache.cache_attributes
expect(attributes).to eq({
options: { cache: cache_config }
})
end
end
end
end
end

View file

@ -252,6 +252,10 @@ RSpec.configure do |config|
# https://gitlab.com/groups/gitlab-org/-/epics/5501
stub_feature_flags(boards_filtered_search: false)
# The following `vue_issues_list` stub can be removed once the
# Vue issues page has feature parity with the current Haml page
stub_feature_flags(vue_issues_list: false)
allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged)
else
unstub_all_feature_flags