Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-05-27 21:10:59 +00:00
parent 479221aa79
commit 95d309bfb8
71 changed files with 1309 additions and 707 deletions

View File

@ -50,9 +50,6 @@ export default {
},
},
computed: {
issuableId() {
return getIdFromGraphQLId(this.issuable.id);
},
createdInPastDay() {
const createdSecondsAgo = differenceInSeconds(new Date(this.issuable.createdAt), new Date());
return createdSecondsAgo < SECONDS_IN_DAY;
@ -64,7 +61,7 @@ export default {
return this.issuable.gitlabWebUrl || this.issuable.webUrl;
},
authorId() {
return getIdFromGraphQLId(this.author.id);
return getIdFromGraphQLId(`${this.author.id}`);
},
isIssuableUrlExternal() {
return isExternal(this.webUrl);
@ -73,10 +70,10 @@ export default {
return this.issuable.labels?.nodes || this.issuable.labels || [];
},
labelIdsString() {
return JSON.stringify(this.labels.map((label) => getIdFromGraphQLId(label.id)));
return JSON.stringify(this.labels.map((label) => label.id));
},
assignees() {
return this.issuable.assignees?.nodes || this.issuable.assignees || [];
return this.issuable.assignees || [];
},
createdAt() {
return sprintf(__('created %{timeAgo}'), {
@ -84,9 +81,6 @@ export default {
});
},
updatedAt() {
if (!this.issuable.updatedAt) {
return '';
}
return sprintf(__('updated %{timeAgo}'), {
timeAgo: getTimeago().format(this.issuable.updatedAt),
});
@ -163,7 +157,7 @@ export default {
<template>
<li
:id="`issuable_${issuableId}`"
:id="`issuable_${issuable.id}`"
class="issue gl-px-5!"
:class="{ closed: issuable.closedAt, today: createdInPastDay }"
:data-labels="labelIdsString"
@ -173,7 +167,7 @@ export default {
<gl-form-checkbox
class="gl-mr-0"
:checked="checked"
:data-id="issuableId"
:data-id="issuable.id"
@input="$emit('checked-input', $event)"
>
<span class="gl-sr-only">{{ issuable.title }}</span>

View File

@ -2,7 +2,6 @@
import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
@ -212,7 +211,7 @@ export default {
},
methods: {
issuableId(issuable) {
return getIdFromGraphQLId(issuable.id) || issuable.iid || uniqueId();
return issuable.id || issuable.iid || uniqueId();
},
issuableChecked(issuable) {
return this.checkedIssuables[this.issuableId(issuable)]?.checked;

View File

@ -42,9 +42,6 @@ export default {
}
return __('Milestone');
},
milestoneLink() {
return this.issue.milestone.webPath || this.issue.milestone.webUrl;
},
dueDate() {
return this.issue.dueDate && dateInWords(new Date(this.issue.dueDate), true);
},
@ -52,7 +49,7 @@ export default {
return isInPast(new Date(this.issue.dueDate));
},
timeEstimate() {
return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate;
return this.issue.timeStats?.humanTimeEstimate;
},
showHealthStatus() {
return this.hasIssuableHealthStatusFeature && this.issue.healthStatus;
@ -88,7 +85,7 @@ export default {
class="issuable-milestone gl-display-none gl-sm-display-inline-block! gl-mr-3"
data-testid="issuable-milestone"
>
<gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate">
<gl-link v-gl-tooltip :href="issue.milestone.webUrl" :title="milestoneDate">
<gl-icon name="clock" />
{{ issue.milestone.title }}
</gl-link>

View File

@ -9,21 +9,24 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import { toNumber } from 'lodash';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import {
API_PARAM,
apiSortParams,
CREATED_DESC,
i18n,
MAX_LIST_SIZE,
PAGE_SIZE,
PARAM_DUE_DATE,
PARAM_PAGE,
PARAM_SORT,
PARAM_STATE,
RELATIVE_POSITION_ASC,
RELATIVE_POSITION_DESC,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
@ -34,19 +37,19 @@ import {
TOKEN_TYPE_MILESTONE,
TOKEN_TYPE_WEIGHT,
UPDATED_DESC,
URL_PARAM,
urlSortParams,
} from '~/issues_list/constants';
import {
convertToApiParams,
convertToParams,
convertToSearchQuery,
convertToUrlParams,
getDueDateValue,
getFilterTokens,
getSortKey,
getSortOptions,
} from '~/issues_list/utils';
import axios from '~/lib/utils/axios_utils';
import { getParameterByName } from '~/lib/utils/common_utils';
import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
import {
DEFAULT_NONE_ANY,
OPERATOR_IS_ONLY,
@ -104,6 +107,9 @@ export default {
emptyStateSvgPath: {
default: '',
},
endpoint: {
default: '',
},
exportCsvPath: {
default: '',
},
@ -167,53 +173,15 @@ export default {
dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)),
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
filterTokens: getFilterTokens(window.location.search),
isLoading: false,
issues: [],
page: 1,
pageInfo: {},
pageParams: {
firstPageSize: PAGE_SIZE,
},
page: toNumber(getParameterByName(PARAM_PAGE)) || 1,
showBulkEditSidebar: false,
sortKey: getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey,
state: state || IssuableStates.Opened,
totalIssues: 0,
};
},
apollo: {
issues: {
query: getIssuesQuery,
variables() {
const filterParams = {
...this.apiFilterParams,
};
if (filterParams.epicId) {
filterParams.epicId = filterParams.epicId.split('::&').pop();
} else if (filterParams.not?.epicId) {
filterParams.not.epicId = filterParams.not.epicId.split('::&').pop();
}
return {
projectPath: this.projectPath,
search: this.searchQuery,
sort: this.sortKey,
state: this.state,
...this.pageParams,
...filterParams,
};
},
update: ({ project }) => project.issues.nodes,
result({ data }) {
this.pageInfo = data.project.issues.pageInfo;
this.totalIssues = data.project.issues.count;
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
},
error() {
createFlash({ message: this.$options.i18n.errorFetchingIssues });
},
debounce: 200,
},
},
computed: {
hasSearch() {
return this.searchQuery || Object.keys(this.urlFilterParams).length;
@ -222,22 +190,16 @@ export default {
return this.showBulkEditSidebar || !this.issues.length;
},
isManualOrdering() {
return this.sortKey === RELATIVE_POSITION_ASC;
return this.sortKey === RELATIVE_POSITION_DESC;
},
isOpenTab() {
return this.state === IssuableStates.Opened;
},
nextPage() {
return Number(this.pageInfo.hasNextPage);
},
previousPage() {
return Number(this.pageInfo.hasPreviousPage);
},
apiFilterParams() {
return convertToApiParams(this.filterTokens);
return convertToParams(this.filterTokens, API_PARAM);
},
urlFilterParams() {
return convertToUrlParams(this.filterTokens);
return convertToParams(this.filterTokens, URL_PARAM);
},
searchQuery() {
return convertToSearchQuery(this.filterTokens) || undefined;
@ -252,7 +214,6 @@ export default {
dataType: 'user',
unique: true,
defaultAuthors: [],
operators: OPERATOR_IS_ONLY,
fetchAuthors: this.fetchUsers,
},
{
@ -279,7 +240,7 @@ export default {
title: TOKEN_TITLE_LABEL,
icon: 'labels',
token: LabelToken,
defaultLabels: DEFAULT_NONE_ANY,
defaultLabels: [],
fetchLabels: this.fetchLabels,
},
];
@ -372,9 +333,10 @@ export default {
return {
due_date: this.dueDateFilter,
page: this.page,
search: this.searchQuery,
sort: urlSortParams[this.sortKey],
state: this.state,
...urlSortParams[this.sortKey],
...filterParams,
};
},
@ -384,6 +346,7 @@ export default {
},
mounted() {
eventHub.$on('issuables:toggleBulkEdit', this.toggleBulkEditSidebar);
this.fetchIssues();
},
beforeDestroy() {
eventHub.$off('issuables:toggleBulkEdit', this.toggleBulkEditSidebar);
@ -423,19 +386,59 @@ export default {
return this.fetchWithCache(this.projectMilestonesPath, 'milestones', 'title', search, true);
},
fetchIterations(search) {
const number = Number(search);
return !search || Number.isNaN(number)
? axios.get(this.projectIterationsPath, { params: { search } })
: axios.get(this.projectIterationsPath, { params: { id: number } });
return axios.get(this.projectIterationsPath, { params: { search } });
},
fetchUsers(search) {
return axios.get(this.autocompleteUsersPath, { params: { search } });
},
fetchIssues() {
if (!this.hasProjectIssues) {
return undefined;
}
this.isLoading = true;
const filterParams = {
...this.apiFilterParams,
};
if (filterParams.epic_id) {
filterParams.epic_id = filterParams.epic_id.split('::&').pop();
} else if (filterParams['not[epic_id]']) {
filterParams['not[epic_id]'] = filterParams['not[epic_id]'].split('::&').pop();
}
return axios
.get(this.endpoint, {
params: {
due_date: this.dueDateFilter,
page: this.page,
per_page: PAGE_SIZE,
search: this.searchQuery,
state: this.state,
with_labels_details: true,
...apiSortParams[this.sortKey],
...filterParams,
},
})
.then(({ data, headers }) => {
this.page = Number(headers['x-page']);
this.totalIssues = Number(headers['x-total']);
this.issues = data.map((issue) => convertObjectPropsToCamelCase(issue, { deep: true }));
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
})
.catch(() => {
createFlash({ message: this.$options.i18n.errorFetchingIssues });
})
.finally(() => {
this.isLoading = false;
});
},
getExportCsvPathWithQuery() {
return `${this.exportCsvPath}${window.location.search}`;
},
getStatus(issue) {
if (issue.closedAt && issue.moved) {
if (issue.closedAt && issue.movedToId) {
return this.$options.i18n.closedMoved;
}
if (issue.closedAt) {
@ -466,30 +469,18 @@ export default {
},
handleClickTab(state) {
if (this.state !== state) {
this.pageParams = {
firstPageSize: PAGE_SIZE,
};
this.page = 1;
}
this.state = state;
this.fetchIssues();
},
handleFilter(filter) {
this.filterTokens = filter;
this.fetchIssues();
},
handlePageChange(page) {
if (page > this.page) {
this.pageParams = {
afterCursor: this.pageInfo.endCursor,
firstPageSize: PAGE_SIZE,
};
} else {
this.pageParams = {
beforeCursor: this.pageInfo.startCursor,
lastPageSize: PAGE_SIZE,
};
}
this.page = page;
this.fetchIssues();
},
handleReorder({ newIndex, oldIndex }) {
const issueToMove = this.issues[oldIndex];
@ -526,6 +517,7 @@ export default {
},
handleSort(value) {
this.sortKey = value;
this.fetchIssues();
},
toggleBulkEditSidebar(showBulkEditSidebar) {
this.showBulkEditSidebar = showBulkEditSidebar;
@ -549,13 +541,14 @@ export default {
:tabs="$options.IssuableListTabs"
:current-tab="state"
:tab-counts="tabCounts"
:issuables-loading="$apollo.loading"
:issuables-loading="isLoading"
:is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="showPaginationControls"
:total-items="totalIssues"
:current-page="page"
:previous-page="previousPage"
:next-page="nextPage"
:previous-page="page - 1"
:next-page="page + 1"
:url-params="urlParams"
@click-tab="handleClickTab"
@filter="handleFilter"
@ -638,7 +631,7 @@ export default {
</li>
<blocking-issues-count
class="gl-display-none gl-sm-display-block"
:blocking-issues-count="issuable.blockedByCount"
:blocking-issues-count="issuable.blockingIssuesCount"
:is-list-item="true"
/>
</template>

View File

@ -101,6 +101,7 @@ export const i18n = {
export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map';
export const PARAM_DUE_DATE = 'due_date';
export const PARAM_PAGE = 'page';
export const PARAM_SORT = 'sort';
export const PARAM_STATE = 'state';
@ -124,21 +125,21 @@ export const CREATED_ASC = 'CREATED_ASC';
export const CREATED_DESC = 'CREATED_DESC';
export const DUE_DATE_ASC = 'DUE_DATE_ASC';
export const DUE_DATE_DESC = 'DUE_DATE_DESC';
export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC';
export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC';
export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC';
export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC';
export const POPULARITY_ASC = 'POPULARITY_ASC';
export const POPULARITY_DESC = 'POPULARITY_DESC';
export const PRIORITY_ASC = 'PRIORITY_ASC';
export const PRIORITY_DESC = 'PRIORITY_DESC';
export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC';
export const RELATIVE_POSITION_DESC = 'RELATIVE_POSITION_DESC';
export const UPDATED_ASC = 'UPDATED_ASC';
export const UPDATED_DESC = 'UPDATED_DESC';
export const WEIGHT_ASC = 'WEIGHT_ASC';
export const WEIGHT_DESC = 'WEIGHT_DESC';
const PRIORITY_ASC_SORT = 'priority_asc';
const SORT_ASC = 'asc';
const SORT_DESC = 'desc';
const CREATED_DATE_SORT = 'created_date';
const CREATED_ASC_SORT = 'created_asc';
const UPDATED_DESC_SORT = 'updated_desc';
@ -146,30 +147,129 @@ const UPDATED_ASC_SORT = 'updated_asc';
const MILESTONE_SORT = 'milestone';
const MILESTONE_DUE_DESC_SORT = 'milestone_due_desc';
const DUE_DATE_DESC_SORT = 'due_date_desc';
const LABEL_PRIORITY_ASC_SORT = 'label_priority_asc';
const POPULARITY_ASC_SORT = 'popularity_asc';
const WEIGHT_DESC_SORT = 'weight_desc';
const BLOCKING_ISSUES_DESC_SORT = 'blocking_issues_desc';
const BLOCKING_ISSUES = 'blocking_issues';
export const apiSortParams = {
[PRIORITY_DESC]: {
order_by: PRIORITY,
sort: SORT_DESC,
},
[CREATED_ASC]: {
order_by: CREATED_AT,
sort: SORT_ASC,
},
[CREATED_DESC]: {
order_by: CREATED_AT,
sort: SORT_DESC,
},
[UPDATED_ASC]: {
order_by: UPDATED_AT,
sort: SORT_ASC,
},
[UPDATED_DESC]: {
order_by: UPDATED_AT,
sort: SORT_DESC,
},
[MILESTONE_DUE_ASC]: {
order_by: MILESTONE_DUE,
sort: SORT_ASC,
},
[MILESTONE_DUE_DESC]: {
order_by: MILESTONE_DUE,
sort: SORT_DESC,
},
[DUE_DATE_ASC]: {
order_by: DUE_DATE,
sort: SORT_ASC,
},
[DUE_DATE_DESC]: {
order_by: DUE_DATE,
sort: SORT_DESC,
},
[POPULARITY_ASC]: {
order_by: POPULARITY,
sort: SORT_ASC,
},
[POPULARITY_DESC]: {
order_by: POPULARITY,
sort: SORT_DESC,
},
[LABEL_PRIORITY_DESC]: {
order_by: LABEL_PRIORITY,
sort: SORT_DESC,
},
[RELATIVE_POSITION_DESC]: {
order_by: RELATIVE_POSITION,
per_page: 100,
sort: SORT_ASC,
},
[WEIGHT_ASC]: {
order_by: WEIGHT,
sort: SORT_ASC,
},
[WEIGHT_DESC]: {
order_by: WEIGHT,
sort: SORT_DESC,
},
[BLOCKING_ISSUES_DESC]: {
order_by: BLOCKING_ISSUES,
sort: SORT_DESC,
},
};
export const urlSortParams = {
[PRIORITY_ASC]: PRIORITY_ASC_SORT,
[PRIORITY_DESC]: PRIORITY,
[CREATED_ASC]: CREATED_ASC_SORT,
[CREATED_DESC]: CREATED_DATE_SORT,
[UPDATED_ASC]: UPDATED_ASC_SORT,
[UPDATED_DESC]: UPDATED_DESC_SORT,
[MILESTONE_DUE_ASC]: MILESTONE_SORT,
[MILESTONE_DUE_DESC]: MILESTONE_DUE_DESC_SORT,
[DUE_DATE_ASC]: DUE_DATE,
[DUE_DATE_DESC]: DUE_DATE_DESC_SORT,
[POPULARITY_ASC]: POPULARITY_ASC_SORT,
[POPULARITY_DESC]: POPULARITY,
[LABEL_PRIORITY_ASC]: LABEL_PRIORITY_ASC_SORT,
[LABEL_PRIORITY_DESC]: LABEL_PRIORITY,
[RELATIVE_POSITION_ASC]: RELATIVE_POSITION,
[WEIGHT_ASC]: WEIGHT,
[WEIGHT_DESC]: WEIGHT_DESC_SORT,
[BLOCKING_ISSUES_DESC]: BLOCKING_ISSUES_DESC_SORT,
[PRIORITY_DESC]: {
sort: PRIORITY,
},
[CREATED_ASC]: {
sort: CREATED_ASC_SORT,
},
[CREATED_DESC]: {
sort: CREATED_DATE_SORT,
},
[UPDATED_ASC]: {
sort: UPDATED_ASC_SORT,
},
[UPDATED_DESC]: {
sort: UPDATED_DESC_SORT,
},
[MILESTONE_DUE_ASC]: {
sort: MILESTONE_SORT,
},
[MILESTONE_DUE_DESC]: {
sort: MILESTONE_DUE_DESC_SORT,
},
[DUE_DATE_ASC]: {
sort: DUE_DATE,
},
[DUE_DATE_DESC]: {
sort: DUE_DATE_DESC_SORT,
},
[POPULARITY_ASC]: {
sort: POPULARITY_ASC_SORT,
},
[POPULARITY_DESC]: {
sort: POPULARITY,
},
[LABEL_PRIORITY_DESC]: {
sort: LABEL_PRIORITY,
},
[RELATIVE_POSITION_DESC]: {
sort: RELATIVE_POSITION,
per_page: 100,
},
[WEIGHT_ASC]: {
sort: WEIGHT,
},
[WEIGHT_DESC]: {
sort: WEIGHT_DESC_SORT,
},
[BLOCKING_ISSUES_DESC]: {
sort: BLOCKING_ISSUES_DESC_SORT,
},
};
export const MAX_LIST_SIZE = 10;
@ -194,7 +294,12 @@ export const TOKEN_TYPE_WEIGHT = 'weight';
export const filters = {
[TOKEN_TYPE_AUTHOR]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'authorUsername',
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'author_username',
},
[OPERATOR_IS_NOT]: {
[NORMAL_FILTER]: 'not[author_username]',
},
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@ -207,8 +312,13 @@ export const filters = {
},
[TOKEN_TYPE_ASSIGNEE]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'assigneeUsernames',
[SPECIAL_FILTER]: 'assigneeId',
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'assignee_username',
[SPECIAL_FILTER]: 'assignee_id',
},
[OPERATOR_IS_NOT]: {
[NORMAL_FILTER]: 'not[assignee_username]',
},
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@ -223,7 +333,12 @@ export const filters = {
},
[TOKEN_TYPE_MILESTONE]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'milestoneTitle',
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'milestone',
},
[OPERATOR_IS_NOT]: {
[NORMAL_FILTER]: 'not[milestone]',
},
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@ -236,13 +351,16 @@ export const filters = {
},
[TOKEN_TYPE_LABEL]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'labelName',
[SPECIAL_FILTER]: 'labelName',
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'labels',
},
[OPERATOR_IS_NOT]: {
[NORMAL_FILTER]: 'not[labels]',
},
},
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'label_name[]',
[SPECIAL_FILTER]: 'label_name[]',
},
[OPERATOR_IS_NOT]: {
[NORMAL_FILTER]: 'not[label_name][]',
@ -251,8 +369,10 @@ export const filters = {
},
[TOKEN_TYPE_MY_REACTION]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'myReactionEmoji',
[SPECIAL_FILTER]: 'myReactionEmoji',
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'my_reaction_emoji',
[SPECIAL_FILTER]: 'my_reaction_emoji',
},
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@ -263,7 +383,9 @@ export const filters = {
},
[TOKEN_TYPE_CONFIDENTIAL]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'confidential',
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'confidential',
},
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@ -273,23 +395,33 @@ export const filters = {
},
[TOKEN_TYPE_ITERATION]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'iterationId',
[SPECIAL_FILTER]: 'iterationWildcardId',
},
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'iteration_id',
[NORMAL_FILTER]: 'iteration_title',
[SPECIAL_FILTER]: 'iteration_id',
},
[OPERATOR_IS_NOT]: {
[NORMAL_FILTER]: 'not[iteration_id]',
[NORMAL_FILTER]: 'not[iteration_title]',
},
},
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'iteration_title',
[SPECIAL_FILTER]: 'iteration_id',
},
[OPERATOR_IS_NOT]: {
[NORMAL_FILTER]: 'not[iteration_title]',
},
},
},
[TOKEN_TYPE_EPIC]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'epicId',
[SPECIAL_FILTER]: 'epicId',
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'epic_id',
[SPECIAL_FILTER]: 'epic_id',
},
[OPERATOR_IS_NOT]: {
[NORMAL_FILTER]: 'not[epic_id]',
},
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@ -303,8 +435,13 @@ export const filters = {
},
[TOKEN_TYPE_WEIGHT]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'weight',
[SPECIAL_FILTER]: 'weight',
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'weight',
[SPECIAL_FILTER]: 'weight',
},
[OPERATOR_IS_NOT]: {
[NORMAL_FILTER]: 'not[weight]',
},
},
[URL_PARAM]: {
[OPERATOR_IS]: {

View File

@ -73,13 +73,6 @@ export function mountIssuesListApp() {
return false;
}
Vue.use(VueApollo);
const defaultClient = createDefaultClient({}, { assumeImmutableResults: true });
const apolloProvider = new VueApollo({
defaultClient,
});
const {
autocompleteAwardEmojisPath,
autocompleteUsersPath,
@ -90,6 +83,7 @@ export function mountIssuesListApp() {
email,
emailsHelpPagePath,
emptyStateSvgPath,
endpoint,
exportCsvPath,
groupEpicsPath,
hasBlockedIssuesFeature,
@ -121,13 +115,14 @@ export function mountIssuesListApp() {
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,
apolloProvider: {},
provide: {
autocompleteAwardEmojisPath,
autocompleteUsersPath,
calendarPath,
canBulkUpdate: parseBoolean(canBulkUpdate),
emptyStateSvgPath,
endpoint,
groupEpicsPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),

View File

@ -1,45 +0,0 @@
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "./issue_info.fragment.graphql"
query getProjectIssues(
$projectPath: ID!
$search: String
$sort: IssueSort
$state: IssuableState
$assigneeId: String
$authorUsername: String
$assigneeUsernames: [String!]
$milestoneTitle: [String]
$labelName: [String]
$not: NegatedIssueFilterInput
$beforeCursor: String
$afterCursor: String
$firstPageSize: Int
$lastPageSize: Int
) {
project(fullPath: $projectPath) {
issues(
search: $search
sort: $sort
state: $state
assigneeId: $assigneeId
authorUsername: $authorUsername
assigneeUsernames: $assigneeUsernames
milestoneTitle: $milestoneTitle
labelName: $labelName
not: $not
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
) {
count
pageInfo {
...PageInfo
}
nodes {
...IssueInfo
}
}
}
}

View File

@ -1,51 +0,0 @@
fragment IssueInfo on Issue {
id
iid
closedAt
confidential
createdAt
downvotes
dueDate
humanTimeEstimate
moved
title
updatedAt
upvotes
userDiscussionsCount
webUrl
assignees {
nodes {
id
avatarUrl
name
username
webUrl
}
}
author {
id
avatarUrl
name
username
webUrl
}
labels {
nodes {
id
color
title
description
}
}
milestone {
id
dueDate
startDate
webPath
title
}
taskCompletionStatus {
completedCount
count
}
}

View File

@ -1,5 +1,4 @@
import {
API_PARAM,
BLOCKING_ISSUES_DESC,
CREATED_ASC,
CREATED_DESC,
@ -7,36 +6,29 @@ import {
DUE_DATE_DESC,
DUE_DATE_VALUES,
filters,
LABEL_PRIORITY_ASC,
LABEL_PRIORITY_DESC,
MILESTONE_DUE_ASC,
MILESTONE_DUE_DESC,
NORMAL_FILTER,
POPULARITY_ASC,
POPULARITY_DESC,
PRIORITY_ASC,
PRIORITY_DESC,
RELATIVE_POSITION_ASC,
RELATIVE_POSITION_DESC,
SPECIAL_FILTER,
SPECIAL_FILTER_VALUES,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_ITERATION,
UPDATED_ASC,
UPDATED_DESC,
URL_PARAM,
urlSortParams,
WEIGHT_ASC,
WEIGHT_DESC,
} from '~/issues_list/constants';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
import {
FILTERED_SEARCH_TERM,
OPERATOR_IS_NOT,
} from '~/vue_shared/components/filtered_search_bar/constants';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
export const getSortKey = (sort) =>
Object.keys(urlSortParams).find((key) => urlSortParams[key] === sort);
Object.keys(urlSortParams).find((key) => urlSortParams[key].sort === sort);
export const getDueDateValue = (value) => (DUE_DATE_VALUES.includes(value) ? value : undefined);
@ -46,7 +38,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
id: 1,
title: __('Priority'),
sortDirection: {
ascending: PRIORITY_ASC,
ascending: PRIORITY_DESC,
descending: PRIORITY_DESC,
},
},
@ -94,7 +86,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
id: 7,
title: __('Label priority'),
sortDirection: {
ascending: LABEL_PRIORITY_ASC,
ascending: LABEL_PRIORITY_DESC,
descending: LABEL_PRIORITY_DESC,
},
},
@ -102,8 +94,8 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
id: 8,
title: __('Manual'),
sortDirection: {
ascending: RELATIVE_POSITION_ASC,
descending: RELATIVE_POSITION_ASC,
ascending: RELATIVE_POSITION_DESC,
descending: RELATIVE_POSITION_DESC,
},
},
];
@ -186,36 +178,12 @@ const getFilterType = (data, tokenType = '') =>
? SPECIAL_FILTER
: NORMAL_FILTER;
const isIterationSpecialValue = (tokenType, value) =>
tokenType === TOKEN_TYPE_ITERATION && SPECIAL_FILTER_VALUES.includes(value);
export const convertToApiParams = (filterTokens) => {
const params = {};
const not = {};
filterTokens
.filter((token) => token.type !== FILTERED_SEARCH_TERM)
.forEach((token) => {
const filterType = getFilterType(token.value.data, token.type);
const field = filters[token.type][API_PARAM][filterType];
const obj = token.value.operator === OPERATOR_IS_NOT ? not : params;
const data = isIterationSpecialValue(token.type, token.value.data)
? token.value.data.toUpperCase()
: token.value.data;
Object.assign(obj, {
[field]: obj[field] ? [obj[field], data].flat() : data,
});
});
return Object.keys(not).length ? Object.assign(params, { not }) : params;
};
export const convertToUrlParams = (filterTokens) =>
export const convertToParams = (filterTokens, paramType) =>
filterTokens
.filter((token) => token.type !== FILTERED_SEARCH_TERM)
.reduce((acc, token) => {
const filterType = getFilterType(token.value.data, token.type);
const param = filters[token.type][URL_PARAM][token.value.operator]?.[filterType];
const param = filters[token.type][paramType][token.value.operator]?.[filterType];
return Object.assign(acc, {
[param]: acc[param] ? [acc[param], token.value.data].flat() : token.value.data,
});

View File

@ -2,6 +2,7 @@
import { GlButton } from '@gitlab/ui';
import createFlash from '~/flash';
import { getParameterByName } from '~/lib/utils/common_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { __ } from '~/locale';
import { PAGE_SIZE } from '~/releases/constants';
import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
@ -9,6 +10,7 @@ import { convertAllReleasesGraphQLResponse } from '~/releases/util';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
import ReleasesEmptyState from './releases_empty_state.vue';
import ReleasesPaginationApolloClient from './releases_pagination_apollo_client.vue';
export default {
name: 'ReleasesIndexApolloClientApp',
@ -17,6 +19,7 @@ export default {
ReleaseBlock,
ReleaseSkeletonLoader,
ReleasesEmptyState,
ReleasesPaginationApolloClient,
},
inject: {
projectPath: {
@ -85,6 +88,16 @@ export default {
return convertAllReleasesGraphQLResponse(this.graphqlResponse).data;
},
pageInfo() {
if (!this.graphqlResponse || this.hasError) {
return {
hasPreviousPage: false,
hasNextPage: false,
};
}
return this.graphqlResponse.data.project.releases.pageInfo;
},
shouldRenderEmptyState() {
return !this.releases.length && !this.hasError && !this.isLoading;
},
@ -94,6 +107,13 @@ export default {
shouldRenderLoadingIndicator() {
return this.isLoading && !this.hasError;
},
shouldRenderPagination() {
return (
!this.isLoading &&
!this.hasError &&
(this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage)
);
},
},
created() {
this.updateQueryParamsFromUrl();
@ -108,6 +128,16 @@ export default {
this.cursors.before = getParameterByName('before');
this.cursors.after = getParameterByName('after');
},
onPaginationButtonPress() {
this.updateQueryParamsFromUrl();
// In some cases, Apollo Client is able to pull its results from the cache instead of making
// a new network request. In these cases, the page's content gets swapped out immediately without
// changing the page's scroll, leaving the user looking at the bottom of the new page.
// To make the experience consistent, regardless of how the data is sourced, we manually
// scroll to the top of the page every time a pagination button is pressed.
scrollUp();
},
},
i18n: {
newRelease: __('New release'),
@ -140,6 +170,13 @@ export default {
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/>
</div>
<releases-pagination-apollo-client
v-if="shouldRenderPagination"
:page-info="pageInfo"
@prev="onPaginationButtonPress"
@next="onPaginationButtonPress"
/>
</div>
</template>
<style>

View File

@ -0,0 +1,37 @@
<script>
import { GlKeysetPagination } from '@gitlab/ui';
import { isBoolean } from 'lodash';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
export default {
name: 'ReleasesPaginationApolloClient',
components: { GlKeysetPagination },
props: {
pageInfo: {
type: Object,
required: true,
validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage),
},
},
methods: {
onPrev(before) {
historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
},
onNext(after) {
historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
},
},
};
</script>
<template>
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
v-bind="pageInfo"
:prev-text="__('Prev')"
:next-text="__('Next')"
v-on="$listeners"
@prev="onPrev($event)"
@next="onNext($event)"
/>
</div>
</template>

View File

@ -39,7 +39,7 @@ export default {
return this.value.data;
},
activeIteration() {
return this.iterations.find((iteration) => iteration.id === Number(this.currentValue));
return this.iterations.find((iteration) => iteration.title === this.currentValue);
},
},
watch: {
@ -99,8 +99,8 @@ export default {
<template v-else>
<gl-filtered-search-suggestion
v-for="iteration in iterations"
:key="iteration.id"
:value="String(iteration.id)"
:key="iteration.title"
:value="iteration.title"
>
{{ iteration.title }}
</gl-filtered-search-suggestion>

View File

@ -51,7 +51,6 @@ module IntegrationsActions
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def integration
@integration ||= find_or_initialize_non_project_specific_integration(params[:id])
@service ||= @integration # TODO: remove references to @service https://gitlab.com/gitlab-org/gitlab/-/issues/329759
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables

View File

@ -185,18 +185,12 @@ class Projects::BranchesController < Projects::ApplicationController
# Here we get one more branch to indicate if there are more data we're not showing
limit = @overview_max_branches + 1
if Feature.enabled?(:branch_list_keyset_pagination, project, default_enabled: :yaml)
@active_branches =
BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_recently_updated })
.execute(gitaly_pagination: true).select(&:active?)
@stale_branches =
BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_oldest_updated })
.execute(gitaly_pagination: true).select(&:stale?)
else
@active_branches, @stale_branches = BranchesFinder.new(@repository, sort: sort_value_recently_updated).execute.partition(&:active?)
@active_branches = @active_branches.first(limit)
@stale_branches = @stale_branches.first(limit)
end
@active_branches =
BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_recently_updated })
.execute(gitaly_pagination: true).select(&:active?)
@stale_branches =
BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_oldest_updated })
.execute(gitaly_pagination: true).select(&:stale?)
@branches = @active_branches + @stale_branches
end

View File

@ -85,14 +85,13 @@ class Projects::ServicesController < Projects::ApplicationController
def integration
@integration ||= @project.find_or_initialize_service(params[:id])
@service ||= @integration # TODO: remove references to @service https://gitlab.com/gitlab-org/gitlab/-/issues/329759
end
alias_method :service, :integration
def web_hook_logs
return unless @service.service_hook.present?
return unless integration.service_hook.present?
@web_hook_logs ||= @service.service_hook.web_hook_logs.recent.page(params[:page])
@web_hook_logs ||= integration.service_hook.web_hook_logs.recent.page(params[:page])
end
def ensure_service_enabled
@ -101,8 +100,8 @@ class Projects::ServicesController < Projects::ApplicationController
def serialize_as_json
integration
.as_json(only: @service.json_fields)
.merge(errors: @service.errors.as_json)
.as_json(only: integration.json_fields)
.merge(errors: integration.errors.as_json)
end
def redirect_deprecated_prometheus_service

View File

@ -9,7 +9,7 @@ module Projects
feature_category :integrations
def show
@services = @project.find_or_initialize_services
@integrations = @project.find_or_initialize_services
end
end
end

View File

@ -190,6 +190,7 @@ module IssuesHelper
email: current_user&.notification_email,
emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'),
empty_state_svg_path: image_path('illustrations/issues.svg'),
endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project),
has_project_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: import_csv_namespace_project_issues_path,

View File

@ -79,7 +79,7 @@
.modal-header
%h3.page-title= _('Delete serverless domain?')
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
%span{ "aria-hidden": "true" } &times;
.modal-body
- if domain_attached

View File

@ -1,5 +1,5 @@
#js-delete-user-modal
#js-modal-texts.hidden{ "hidden": true, "aria-hidden": true }
#js-modal-texts.hidden{ "hidden": true, "aria-hidden": "true" }
%div{ data: { modal: "delete",
title: s_("AdminUsers|Delete User %{username}?"),
action: s_('AdminUsers|Delete user'),

View File

@ -10,7 +10,7 @@
%li
%button.js-shortcuts-modal-trigger{ type: "button" }
= _("Keyboard shortcuts")
%span.text-secondary.float-right{ "aria-hidden": true }= '?'.html_safe
%span.text-secondary.float-right{ "aria-hidden": "true" }= '?'.html_safe
%li.divider
%li
= link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback"

View File

@ -135,7 +135,7 @@
%h4.modal-title
= s_("Profiles|Position and size your new avatar")
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _("Close") }
%span{ "aria-hidden": true } &times;
%span{ "aria-hidden": "true" } &times;
.modal-body
.profile-crop-image-container
%img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") }

View File

@ -4,7 +4,7 @@
.modal-header
%h3.modal-title Import projects from Bitbucket
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
%span{ "aria-hidden": "true" } &times;
.modal-body
To enable importing projects from Bitbucket,
- if current_user.admin?

View File

@ -4,7 +4,7 @@
.modal-header
%h3.modal-title Import projects from GitLab.com
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
%span{ "aria-hidden": "true" } &times;
.modal-body
To enable importing projects from GitLab.com,
- if current_user.admin?

View File

@ -7,7 +7,7 @@
.modal-header
%h3.page-title= _('Reduce this projects visibility?')
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true }= sprite_icon("close")
%span{ "aria-hidden": "true" }= sprite_icon("close")
.modal-body
%p
- if @project.group

View File

@ -4,7 +4,7 @@
.modal-header
%h3.page-title= _('Create New Directory')
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
%span{ "aria-hidden": "true" } &times;
.modal-body
= form_tag project_create_dir_path(@project, @id), method: :post, remote: false, class: 'js-create-dir-form js-quick-submit js-requires-input' do
.form-group.row

View File

@ -4,7 +4,7 @@
.modal-header
%h3.page-title Delete #{@blob.name}
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
%span{ "aria-hidden": "true" } &times;
.modal-body
= form_tag project_blob_path(@project, @id), method: :delete, class: 'js-delete-blob-form js-quick-submit js-requires-input' do

View File

@ -4,7 +4,7 @@
.modal-header
%h3.page-title= title
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
%span{ "aria-hidden": "true" } &times;
.modal-body
= form_tag form_path, method: method, class: 'js-quick-submit js-upload-blob-form', data: { method: method } do
.dropzone

View File

@ -7,7 +7,7 @@
%span.js-branch-name.ref-name>[branch name]
= s_("Branches|Delete protected branch '%{branch_name}'?").html_safe % { branch_name: title_branch_name }
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
%span{ "aria-hidden": "true" } &times;
.modal-body
%p

View File

@ -6,4 +6,4 @@
- integrations_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('user/project/integrations/overview') }
- webhooks_link_start = '<a href="%{url}">'.html_safe % { url: project_hooks_path(@project) }
%p= _("%{integrations_link_start}Integrations%{link_end} enable you to make third-party applications part of your GitLab workflow. If the available integrations don't meet your needs, consider using a %{webhooks_link_start}webhook%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, webhooks_link_start: webhooks_link_start, link_end: '</a>'.html_safe }
= render 'shared/integrations/index', integrations: @services
= render 'shared/integrations/index', integrations: @integrations

View File

@ -4,7 +4,7 @@
.modal-header
%h3.page-title= _('Fork project?')
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
%span{ "aria-hidden": "true" } &times;
.modal-body.p-3
%p= _("You cant %{tag_start}edit%{tag_end} files directly in this project. Fork this project and submit a merge request with your changes.") % { tag_start: '', tag_end: ''}
.modal-footer

View File

@ -4,7 +4,7 @@
.modal-header
%h3.page-title= _('Confirmation required')
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
%span{ "aria-hidden": "true" } &times;
.modal-body
%p.text-danger.js-confirm-text

View File

@ -1,7 +1,7 @@
.form-group.row.js-template-warning.hidden.js-issuable-template-warning
.col-sm-12
.warning_message.mb-0{ role: 'alert' }
%btn.js-close-btn.js-dismiss-btn.close{ type: "button", "aria-hidden": true, "aria-label": _("Close") }
%btn.js-close-btn.js-dismiss-btn.close{ type: "button", "aria-hidden": "true", "aria-label": _("Close") }
= sprite_icon("close")
%p

View File

@ -4,7 +4,7 @@
.modal-header
%h3.page-title= _('Enable Gitpod?')
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
%span{ "aria-hidden": "true" } &times;
.modal-body.p-3
%p= (_("To use Gitpod you must first enable the feature in the integrations section of your %{user_prefs}.") % { user_prefs: link_to(_('user preferences'), profile_preferences_path(anchor: 'gitpod')) }).html_safe
.modal-footer

View File

@ -21,8 +21,6 @@ component may increase reliability and availability through redundancy.
When database load balancing is enabled in GitLab, the load is balanced using
a simple round-robin algorithm, without any external dependencies such as Redis.
Load balancing is not enabled for Sidekiq as this would lead to consistency
problems, and Sidekiq mostly performs writes anyway.
In the following image, you can see the load is balanced rather evenly among
all the secondaries (`db4`, `db5`, `db6`). Because `SELECT` queries are not
@ -105,6 +103,32 @@ the following. This will balance the load between `host1.example.com` and
1. Save the file and [restart GitLab](restart_gitlab.md#installations-from-source) for the changes to take effect.
### Enable the load balancer for Sidekiq
Sidekiq mostly writes to the database, which means that most of its traffic hits the
primary database.
Some background jobs can use database replicas to read application state.
This allows to offload the primary database.
Load balancing is disabled by default in Sidekiq. When enabled, we can define
[the data consistency](../development/sidekiq_style_guide.md#job-data-consistency)
requirements for a specific job.
To enable it, define the `ENABLE_LOAD_BALANCING_FOR_SIDEKIQ` variable to the environment, as shown below.
For Omnibus installations:
```ruby
gitlab_rails['env'] = {"ENABLE_LOAD_BALANCING_FOR_SIDEKIQ" => "true"}
```
For installations from source:
```shell
export ENABLE_LOAD_BALANCING_FOR_SIDEKIQ="true"
```
## Service Discovery
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/5883) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.0.

View File

@ -4,7 +4,7 @@ group: Distribution
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/#assignments
---
# Debugging Tips
# Debugging tips **(FREE SELF)**
Sometimes things don't work the way they should. Here are some tips on debugging issues out
in production.

View File

@ -5,21 +5,21 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: reference
---
# Disaster Recovery
# Disaster recovery **(FREE SELF)**
This document describes a feature that allows to easily disable some important but computationally
expensive parts of the application, in order to relieve stress on the database in an ongoing downtime.
This document describes a feature that allows you to disable some important but computationally
expensive parts of the application to relieve stress on the database during an ongoing downtime.
## `ci_queueing_disaster_recovery`
This feature flag, if enabled temporarily disables fair scheduling on shared runners.
This can help reduce system resource usage on the `jobs/request` endpoint
by significantly reducing computations being performed.
This feature flag, if temporarily enabled, disables fair scheduling on shared runners.
This can help to reduce system resource usage on the `jobs/request` endpoint
by significantly reducing the computations being performed.
Side effects:
- In case of a large backlog of jobs, the jobs will be processed in the order
they were put in the system instead of balancing the jobs across many projects
- In case of a large backlog of jobs, the jobs are processed in the order
they were put in the system, instead of balancing the jobs across many projects.
- Projects which are out of quota will be run. This affects
only jobs that were created during the last hour, as prior jobs are canceled
by a periodic background worker (`StuckCiJobsWorker`).
only jobs created during the last hour, as prior jobs are canceled
by a periodic background worker (`StuckCiJobsWorker`).

View File

@ -5,7 +5,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: reference
---
# Diagnostics tools
# Diagnostics tools **(FREE SELF)**
These are some of the diagnostics tools the GitLab Support team uses during troubleshooting.
They are listed here for transparency, and they may be useful for users with experience

View File

@ -356,6 +356,16 @@ DeployKeysProject.with_write_access.find_each do |deploy_key_mapping|
end
```
### Find projects using an SQL query
Find and store an array of projects based on an SQL query:
```ruby
# Finds projects that end with '%ject'
projects = Project.find_by_sql("SELECT * FROM projects WHERE name LIKE '%ject'")
=> [#<Project id:12 root/my-first-project>>, #<Project id:13 root/my-second-project>>]
```
## Wikis
### Recreate
@ -709,6 +719,16 @@ emails.each do |e|
end
```
### Find groups using an SQL query
Find and store an array of groups based on an SQL query:
```ruby
# Finds groups and subgroups that end with '%oup'
Group.find_by_sql("SELECT * FROM namespaces WHERE name LIKE '%oup'")
=> [#<Group id:3 @test-group>, #<Group id:4 @template-group/template-subgroup>]
```
## Routes
### Remove redirecting routes

View File

@ -4,7 +4,7 @@ group: Distribution
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/#assignments
---
# Troubleshooting a GitLab installation
# Troubleshooting a GitLab installation **(FREE SELF)**
This page documents a collection of resources to help you troubleshoot a GitLab
installation.

View File

@ -5,7 +5,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: reference
---
# Kubernetes, GitLab and You
# Kubernetes, GitLab, and you **(FREE SELF)**
This is a list of useful information regarding Kubernetes that the GitLab Support
Team sometimes uses while troubleshooting. GitLab is making this public, so that anyone

View File

@ -5,7 +5,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: reference
---
# Linux Cheat Sheet
# Linux cheat sheet **(FREE SELF)**
This is the GitLab Support Team's collection of information regarding Linux, that they
sometimes use while troubleshooting. It is listed here for transparency,

View File

@ -4,7 +4,7 @@ group: Distribution
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/#assignments
---
# Parsing GitLab logs with `jq`
# Parsing GitLab logs with `jq` **(FREE SELF)**
We recommend using log aggregation and search tools like Kibana and Splunk whenever possible,
but if they are not available you can still quickly parse

View File

@ -4,7 +4,7 @@ group: Distribution
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/#assignments
---
# Navigating GitLab via Rails console
# Navigating GitLab via Rails console **(FREE SELF)**
At the heart of GitLab is a web application [built using the Ruby on Rails
framework](https://about.gitlab.com/blog/2018/10/29/why-we-use-rails-to-build-gitlab/).

View File

@ -4,7 +4,7 @@ group: Distribution
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/#assignments
---
# Troubleshooting Sidekiq
# Troubleshooting Sidekiq **(FREE SELF)**
Sidekiq is the background job processor GitLab uses to asynchronously run
tasks. When things go wrong it can be difficult to troubleshoot. These

View File

@ -5,7 +5,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: reference
---
# Troubleshooting SSL
# Troubleshooting SSL **(FREE SELF)**
This page contains a list of common SSL-related errors and scenarios that you
may encounter while working with GitLab. It should serve as an addition to the

View File

@ -5,7 +5,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: reference
---
# Apps for a Testing Environment
# Apps for a testing environment **(FREE SELF)**
This is the GitLab Support Team's collection of information regarding testing environments,
for use while troubleshooting. It is listed here for transparency, and it may be useful

View File

@ -5,7 +5,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: reference
---
# Finding relevant log entries with a correlation ID
# Finding relevant log entries with a correlation ID **(FREE SELF)**
In GitLab 11.6 and later, a unique request tracking ID, known as the "correlation ID" has been
logged by the GitLab instance for most requests. Each individual request to GitLab gets

View File

@ -154,6 +154,12 @@ A good example of that would be a cache expiration worker.
A job scheduled for an idempotent worker is [deduplicated](#deduplication) when
an unstarted job with the same arguments is already in the queue.
WARNING:
For [data consistency jobs](#job-data-consistency), the deduplication is not compatible with the
`data_consistency` attribute set to `:sticky` or `:delayed`.
The reason for this is that deduplication always takes into account the latest binary replication pointer into account, not the first one.
There is an [open issue](https://gitlab.com/gitlab-org/gitlab/-/issues/325291) to improve this.
### Ensuring a worker is idempotent
Make sure the worker tests pass using the following shared example:
@ -456,6 +462,68 @@ If we expect an increase of **less than 5%**, then no further action is needed.
Otherwise, please ping `@gitlab-org/scalability` on the merge request and ask
for a review.
## Job data consistency
In order to utilize [Sidekiq read-only database replicas capabilities](../administration/database_load_balancing.md#enable-the-load-balancer-for-sidekiq),
set the `data_consistency` attribute of the job to `:always`, `:sticky`, or `:delayed`.
| **Data Consistency** | **Description** |
|--------------|-----------------------------|
| `:always` | The job is required to use the primary database (default). |
| `:sticky` | The job uses a replica as long as possible. It switches to primary either on write or long replication lag. It should be used on jobs that require to be executed as fast as possible. |
| `:delayed` | The job always uses replica, but switches to primary on write. The job is delayed if there's a long replication lag. If the replica is not up-to-date with the next retry, it switches to the primary. It should be used on jobs where we are fine to delay the execution of a given job due to their importance such as expire caches, execute hooks, etc. |
To set a data consistency for a job, use the `data_consistency` class method:
```ruby
class DelayedWorker
include ApplicationWorker
data_consistency :delayed
# ...
end
```
For [idempotent jobs](#idempotent-jobs), the deduplication is not compatible with the
`data_consistency` attribute set to `:sticky` or `:delayed`.
The reason for this is that deduplication always takes into account the latest binary replication pointer into account, not the first one.
There is an [open issue](https://gitlab.com/gitlab-org/gitlab/-/issues/325291) to improve this.
### `feature_flag` property
The `feature_flag` property allows you to toggle a job's `data_consistency`,
which permits you to safely toggle load balancing capabilities for a specific job.
When `feature_flag` is disabled, the job defaults to `:always`, which means that the job will always use the primary database.
The `feature_flag` property does not allow the use of
[feature gates based on actors](../development/feature_flags/index.md).
This means that the feature flag cannot be toggled only for particular
projects, groups, or users, but instead, you can safely use [percentage of time rollout](../development/feature_flags/index.md).
Note that since we check the feature flag on both Sidekiq client and server, rolling out a 10% of the time,
will likely results in 1% (0.1 [from client]*0.1 [from server]) of effective jobs using replicas.
Example:
```ruby
class DelayedWorker
include ApplicationWorker
data_consistency :delayed, feature_flag: :load_balancing_for_delayed_worker
# ...
end
```
### Delayed job execution
Scheduling workers that utilize [Sidekiq read-only database replicas capabilities](#job-data-consistency),
(workers with `data_consistency` attribute set to `:sticky` or `:delayed`),
by calling `SomeWorker.perform_async` results in a worker performing in the future (1 second in the future).
This way, the replica has a chance to catch up, and the job will likely use the replica.
For workers with `data_consistency` set to `:delayed`, it can also reduce the number of retried jobs.
## Jobs with External Dependencies
Most background jobs in the GitLab application communicate with other GitLab

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -12,8 +12,8 @@ have access to. Subscriptions are valid for 12 months.
GitLab provides special subscriptions to participants in:
- [Education](#gitlab-for-education-subscriptions)
- [Open Source](#gitlab-for-open-source-subscriptions)
- [Education](#gitlab-for-education)
- [Open Source](#gitlab-for-open-source)
## Choose a GitLab subscription
@ -68,7 +68,7 @@ click D "./gitlab_com/index.html#view-your-gitlabcom-subscription"
click E "./self_managed/index.html#view-your-subscription"
```
## Customers portal
## Customers Portal
With the [Customers Portal](https://customers.gitlab.com/) you can:
@ -165,20 +165,102 @@ To change the password for this customers portal account:
1. Make the required changes to the **Your password** section.
1. Click **Save changes**.
## GitLab for Education subscriptions
## Community program subscriptions
The GitLab Education license can only be used for instructional-use or
### GitLab for Education
For qualifying non-profit educational institutions, the [GitLab for Education](https://about.gitlab.com/solutions/education/) program provides
the top GitLab tier, plus 50,000 CI minutes per month.
The GitLab for Education license can only be used for instructional-use or
non-commercial academic research.
Find more information how to apply and renew at
Find more information on how to apply and renew at
[GitLab for Education](https://about.gitlab.com/solutions/education/).
## GitLab for Open Source subscriptions
### GitLab for Open Source
For qualifying open source projects, the [GitLab for Open Source](https://about.gitlab.com/solutions/open-source/) program provides
the top GitLab tier, plus 50,000 CI minutes per month.
You can find more information about the [program requirements](https://about.gitlab.com/solutions/open-source/join/#requirements),
[renewals](https://about.gitlab.com/solutions/open-source/join/$renewals),
and benefits on the [GitLab for Open Source application page](https://about.gitlab.com/solutions/open-source/join/).
All [GitLab for Open Source](https://about.gitlab.com/solutions/open-source/)
requests, including subscription renewals, must be made by using the application process.
If you have any questions, send an email to `opensource@gitlab.com` for assistance.
#### Verification for Open Source program
As part of the [application verification process](https://about.gitlab.com/solutions/open-source/join/), you must upload three screenshots.
These screenshots are needed to qualify you for the GitLab for Open Source program.
- [OSI-approved license overview](#license-overview)
- [OSI-approved license file](#license-file)
- [Publicly visible settings](#publicly-visible-settings)
##### OSI-approved license
You must apply an [OSI-approved license](https://opensource.org/licenses/) to each project in your group before you can be verified.
Add the license to the LICENSE file so that it shows up in the overview section of the project. This allows contributors to see it at a glance.
It's best to copy and paste the entire license into the file in its original form. GitLab defaults to **All rights reserved** if no license file is mentioned.
You must ensure that you add the correct license to each project within your group.
After you ensure that you are using OSI-approved licenses for your projects, you can take your screenshots.
###### License overview
Go to **Project Overview > Details**. Take a screenshot that includes a view of the license you've chosen for your project.
![License overview](img/license-overview.png)
###### License file
Navigate to one of the license files that you uploaded. You can usually find the license file by selecting **Project Overview > Details** and scanning the page for the license.
Make sure the screenshot includes the title of the license.
![License file](img/license-file.png)
##### Publicly visible settings
The goal of the GitLab for Open Source program is to enable collaboration on open source projects.
As a pre-condition to collaboration, people must be able to view the open source project.
As a result, we ask that all projects under this license are publicly visible.
Follow these instructions to take a screenshot of the publicly visible settings:
1. Go to your project and select **Settings**.
1. Expand **Visibility, project features, permissions**.
1. Set **Project Visibility** to **Public**.
1. Ensure others can request access by selecting the **Users can request access** checkbox.
1. Take the screenshot. Include as much of the publicly visible settings as possible. Make sure to include your project's name in the
upper-left of the screenshot.
![Publicly visible setting](img/publicly-visible.png)
NOTE:
From time to time, GitLab allows exceptions. One or two projects within a group can be private if there is a legitimate need for it, for example,
if a project holds sensitive data. Email `opensource@gitlab.com` with details of your use case to request written permission for exceptions.
### GitLab for Startups
For qualifying startups, the [GitLab for Startups](https://about.gitlab.com/solutions/startups/) program provides
the top GitLab tier, plus 50,000 CI minutes per month for 12 months.
For more information, including program requirements, see the [Startup program's landing page](https://about.gitlab.com/solutions/startups/).
Send all questions and requests related to the GitLab for Startups program to `startups@gitlab.com`.
### Support for Community Programs
Because these Community Programs are free of cost, regular Priority Support is not included. However, it can be purchased at a 95% discount in some cases.
If interested, email the relevant community program team: `education@gitlab.com`, `opensource@gitlab.com`, or `startups@gitlab.com`.
As a community member, you can follow this diagram to find support:
![Support diagram](img/support-diagram.png)
## Contact Support
Learn more about:

View File

@ -43,6 +43,10 @@ To make full use of Auto DevOps with Kubernetes, you need:
the NGINX Ingress deployment to be scraped by Prometheus using
`prometheus.io/scrape: "true"` and `prometheus.io/port: "10254"`.
NOTE:
If your cluster is installed on bare metal, see
[Auto DevOps Requirements for bare metal](#auto-devops-requirements-for-bare-metal).
- **Base domain** (for [Auto Review Apps](stages.md#auto-review-apps),
[Auto Deploy](stages.md#auto-deploy), and [Auto Monitoring](stages.md#auto-monitoring))
@ -149,3 +153,18 @@ specific CI/CD variable.
For more details, see [Custom build job for Auto DevOps](../../ci/cloud_deployment/index.md#custom-build-job-for-auto-devops)
for deployments to AWS EC2.
## Auto DevOps requirements for bare metal
According to the [Kubernetes Ingress-NGINX docs](https://kubernetes.github.io/ingress-nginx/deploy/baremetal/):
> In traditional cloud environments, where network load balancers are available on-demand,
a single Kubernetes manifest suffices to provide a single point of contact to the NGINX Ingress
controller to external clients and, indirectly, to any application running inside the cluster.
Bare-metal environments lack this commodity, requiring a slightly different setup to offer the
same kind of access to external consumers.
The docs linked above explain the issue and present possible solutions, for example:
- Through [MetalLB](https://github.com/metallb/metallb).
- Through [PorterLB](https://github.com/kubesphere/porterlb).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -36,7 +36,7 @@ You can undo changes at any point in this workflow:
- [When you're working locally](#undo-local-changes) and haven't yet pushed to a remote repository.
- When you have already pushed to a remote repository and you want to:
- [Keep the history intact](#undo-remote-changes-without-changing-history) (preferred).
- [Change the history](#undo-remote-changes-with-modifying-history) (requires
- [Change the history](#undo-remote-changes-while-changing-history) (requires
coordination with team and force pushes).
## Undo local changes
@ -152,7 +152,7 @@ If you want to change to another branch, you can use [`git stash`](https://www.g
## Undo committed local changes
When you commit to your local repository (`git commit`), the version control system records
When you commit to your local repository (`git commit`), Git records
your changes. Because you did not push to a remote repository yet, your changes are
not public (or shared with other developers). At this point, you can undo your changes.
@ -218,64 +218,53 @@ which clashes with what other developers have locally.
### Undo staged local changes with history modification
You can rewrite history in Git, but you should avoid it, because it can cause problems
when multiple developers are contributing to the same codebase.
The following tasks rewrite Git history.
There is one command for history modification and that is `git rebase`. Command
provides interactive mode (`-i` flag) which enables you to:
#### Delete a specific commit
- **reword** commit messages (there is also `git commit --amend` for editing
last commit message).
- **edit** the commit content (changes introduced by commit) and message.
- **squash** multiple commits into a single one, and have a custom or aggregated
commit message.
- **drop** commits - delete them.
- and few more options.
You can delete a specific commit. For example, if you have
commits `A-B-C-D` and you want to delete commit `B`.
Let us check few examples. Again there are commits `A-B-C-D` where you want to
delete commit `B`.
1. Rebase the range from current commit `D` to `B`:
- Rebase the range from current commit D to A:
```shell
git rebase -i A
```
```shell
git rebase -i A
```
A list of commits is displayed in your editor.
- Command opens your favorite editor where you write `drop` in front of commit
`B`, but you leave default `pick` with all other commits. Save and exit the
editor to perform a rebase. Remember: if you want to cancel delete whole
file content before saving and exiting the editor
1. In front of commit `B`, replace `pick` with `drop`.
1. Leave the default, `pick`, for all other commits.
1. Save and exit the editor.
In case you want to modify something introduced in commit `B`.
#### Modify a specific commit
- Rebase the range from current commit D to A:
You can modify a specific commit. For example, if you have
commits `A-B-C-D` and you want to modify something introduced in commit `B`.
```shell
git rebase -i A
```
1. Rebase the range from current commit `D` to `B`:
- Command opens your favorite text editor where you write `edit` in front of commit
`B`, but leave default `pick` with all other commits. Save and exit the editor to
perform a rebase.
```shell
git rebase -i A
```
- Now do your edits and commit changes:
A list of commits is displayed in your editor.
1. In front of commit `B`, replace `pick` with `edit`.
1. Leave the default, `pick`, for all other commits.
1. Save and exit the editor.
1. Open the file in your editor, make your edits, and commit the changes:
```shell
git commit -a
```
You can find some more examples in the section explaining
[how to modify history](#how-modifying-history-is-done).
```shell
git commit -a
```
### Redoing the undo
Sometimes you realize that the changes you undid were useful and you want them
back. Well because of first paragraph you are in luck. Command `git reflog`
enables you to *recall* detached local commits by referencing or applying them
via commit ID. Although, do not expect to see really old commits in reflog, because
Git regularly [cleans the commits which are *unreachable* by branches or tags](https://git-scm.com/book/en/v2/Git-Internals-Maintenance-and-Data-Recovery).
You can recall previous local commits. However, not all previous commits are available, because
Git regularly [cleans the commits that are unreachable by branches or tags](https://git-scm.com/book/en/v2/Git-Internals-Maintenance-and-Data-Recovery).
To view repository history and to track older commits you can use below command:
To view repository history and track prior commits, run `git reflog show`. For example:
```shell
$ git reflog show
@ -293,63 +282,46 @@ eb37e74 HEAD@{6}: rebase -i (pick): Commit C
6e43d59 HEAD@{16}: commit: Commit B
```
Output of command shows repository history. In first column there is commit ID,
in following column, number next to `HEAD` indicates how many commits ago something
was made, after that indicator of action that was made (commit, rebase, merge, ...)
and then on end description of that action.
This output shows the repository history, including:
- The commit SHA.
- How many `HEAD`-changing actions ago the commit was made (`HEAD@{12}` was 12 `HEAD`-changing actions ago).
- The action that was taken, for example: commit, rebase, merge.
- A description of the action that changed `HEAD`.
## Undo remote changes without changing history
This topic is roughly same as modifying committed local changes without modifying
history. **It should be the preferred way of undoing changes on any remote repository
or public branch.** Keep in mind that branching is the best solution when you want
to retain the history of faulty development, yet start anew from certain point.
Branching
enables you to include the existing changes in new development (by merging) and
it also provides a clear timeline and development structure.
To undo changes in the remote repository, you can create a new commit with the changes you
want to undo. You should follow this process, which preserves the history and
provides a clear timeline and development structure. However, you only
need to follow this procedure if your work was merged into a branch that
other developers are using as the base for their work (for example, `main`).
![Use revert to keep branch flowing](img/revert.png)
If you want to revert changes introduced in certain `commit-id`, you can
revert that `commit-id` (swap additions and deletions) in newly created commit:
You can do this with
To revert changes introduced in a specific commit `B`:
```shell
git revert commit-id
git revert B
```
or creating a new branch:
## Undo remote changes while changing history
```shell
git checkout commit-id
git checkout -b new-path-of-feature
```
You can undo remote changes and change history.
## Undo remote changes with modifying history
This is useful when you want to *hide* certain things - like secret keys,
passwords, and SSH keys. It is and should not be used to hide mistakes, as
it makes it harder to debug in case there are some other bugs. The main
reason for this is that you loose the real development progress. Keep in
mind that, even with modified history, commits are just detached and can still be
accessed through commit ID - at least until all repositories perform
the automated cleanup of detached commits.
Even with an updated history, old commits can still be
accessed by commit SHA, at least until all the automated cleanup
of detached commits is performed, or a cleanup is run manually. Even the cleanup might not remove old commits if there are still refs pointing to them.
![Modifying history causes problems on remote branch](img/rebase_reset.png)
### Where modifying history is generally acceptable
### When changing history is acceptable
Modified history breaks the development chain of other developers, as changed
history does not have matching commit IDs. For that reason it should not be
used on any public branch or on branch that might be used by other developers.
When contributing to big open source repositories (for example, [GitLab](https://gitlab.com/gitlab-org/gitlab/blob/master/CONTRIBUTING.md#contribution-acceptance-criteria)
itself), it is acceptable to squash commits into a single one, to present a
nicer history of your contribution.
You should not change the history when you're working in a public branch
or a branch that might be used by other developers.
Keep in mind that this also removes the comments attached to certain commits
in merge requests, so if you need to retain traceability in GitLab, then
modifying history is not acceptable.
When you contribute to large open source repositories, like [GitLab](https://gitlab.com/gitlab-org/gitlab),
you can squash your commits into a single one.
A feature branch of a merge request is a public branch and might be used by
other developers, but project process and rules might allow or require
@ -362,20 +334,12 @@ at merge).
NOTE:
Never modify the commit history of your [default branch](../../../user/project/repository/branches/default.md) or shared branch.
### How modifying history is done
### How to change history
After you know what you want to modify (how far in history or how which range of
old commits), use `git rebase -i commit-id`. This command displays all the commits from
current version to chosen commit ID and allow modification, squashing, deletion
of that commits.
You can modify history by using `git rebase -i`. This command allows modification, squashing, deletion
of commits.
```shell
$ git rebase -i commit1-id..commit3-id
pick <commit1-id> <commit1-commit-message>
pick <commit2-id> <commit2-commit-message>
pick <commit3-id> <commit3-commit-message>
# Rebase commit1-id..commit3-id onto <commit4-id> (3 command(s))
#
# Commands:
# p, pick = use commit
@ -388,17 +352,16 @@ pick <commit3-id> <commit3-commit-message>
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
# If you remove a line THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out
# Empty commits are commented out
```
NOTE:
The comment from the output clearly states that, if
you decide to abort, don't just close your editor (as that
modifies history), but remove all uncommented lines and save.
If you decide to abort, do not close your editor, because the history
will change. Instead, remove all uncommented lines and save.
Use `git rebase` carefully on
shared and remote branches, but rest assured: nothing is broken until

View File

@ -24,6 +24,10 @@ module Gitlab
self
end
def compact
Collection.new(select { |variable| !variable.value.nil? })
end
def concat(resources)
return self if resources.nil?

View File

@ -21803,9 +21803,6 @@ msgstr ""
msgid "NetworkPolicies|Edit policy"
msgstr ""
msgid "NetworkPolicies|Editor mode"
msgstr ""
msgid "NetworkPolicies|Enforcement status"
msgstr ""
@ -21905,9 +21902,6 @@ msgstr ""
msgid "NetworkPolicies|Unable to parse policy"
msgstr ""
msgid "NetworkPolicies|YAML editor"
msgstr ""
msgid "NetworkPolicies|all DNS names"
msgstr ""

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
module RuboCop
module Cop
module UsageData
# This cop checks that histogram method is not used in usage_data.rb files
# for models representing large tables, as defined by migration helpers.
#
# @example
#
# # bad
# histogram(Issue, buckets: 1..100)
# histogram(User.active, buckets: 1..100)
class HistogramWithLargeTable < RuboCop::Cop::Cop
MSG = 'Avoid histogram method on %{model_name}'
# Match one level const as Issue, Gitlab
def_node_matcher :one_level_node, <<~PATTERN
(send nil? :histogram
`(const {nil? cbase} $_)
...)
PATTERN
# Match two level const as ::Clusters::Cluster, ::Ci::Pipeline
def_node_matcher :two_level_node, <<~PATTERN
(send nil? :histogram
`(const
(const {nil? cbase} $_)
$_)
...)
PATTERN
def on_send(node)
one_level_matches = one_level_node(node)
two_level_matches = two_level_node(node)
return unless Array(one_level_matches).any? || Array(two_level_matches).any?
class_name = two_level_matches ? two_level_matches.join('::').to_sym : one_level_matches
if large_table?(class_name)
add_offense(node, location: :expression, message: format(MSG, model_name: class_name))
end
end
private
def large_table?(model)
high_traffic_models.include?(model.to_s)
end
def high_traffic_models
cop_config['HighTrafficModels'] || []
end
end
end
end
end

View File

@ -39,6 +39,64 @@ UsageData/LargeTable:
- :arel_table
- :minimum
- :maximum
UsageData/HistogramWithLargeTable:
Enabled: true
Include:
- 'lib/gitlab/usage_data.rb'
- 'ee/lib/ee/gitlab/usage_data.rb'
HighTrafficModels: &high_traffic_models # models for all high traffic tables in Migration/UpdateLargeTable
- 'AuditEvent'
- 'Ci::BuildTraceSection'
- 'CommitStatus'
- 'Ci::Processable'
- 'Ci::Bridge'
- 'Ci::Build'
- 'GenericCommitStatus'
- 'Ci::BuildMetadata'
- 'Ci::JobArtifact'
- 'Ci::PipelineVariable'
- 'Ci::Pipeline'
- 'Ci::Stage'
- 'Deployment'
- 'Event'
- 'PushEvent'
- 'Issue'
- 'MergeRequestDiffCommit'
- 'MergeRequestDiffFile'
- 'MergeRequestDiff'
- 'MergeRequest::Metrics'
- 'MergeRequest'
- 'NamespaceSetting'
- 'Namespace'
- 'Group'
- 'NoteDiffFile'
- 'Note'
- 'DiffNote'
- 'DiscussionNote'
- 'SyntheticNote'
- 'LabelNote'
- 'MilestoneNote'
- 'StateNote'
- 'LegacyDiffNote'
- 'ProjectAuthorization'
- 'Project'
- 'ProjectCiCdSetting'
- 'ProjectSetting'
- 'ProjectFeature'
- 'ProtectedBranch'
- 'ExportedProtectedBranch'
- 'PushEventPayload'
- 'ResourceLabelEvent'
- 'Route'
- 'SentNotification'
- 'SystemNoteMetadata'
- 'ActsAsTaggableOn::Tagging'
- 'Todo'
- 'User'
- 'UserPreference'
- 'UserDetail'
- 'Vulnerabilities::Finding'
- 'WebHookLog'
UsageData/DistinctCountByLargeForeignKey:
Enabled: true
Include:

View File

@ -655,21 +655,6 @@ RSpec.describe Projects::BranchesController do
["feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'"]
)
end
context 'branch_list_keyset_pagination is disabled' do
before do
stub_feature_flags(branch_list_keyset_pagination: false)
end
it 'sets active and stale branches' do
expect(assigns[:active_branches].map(&:name)).not_to include(
"feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'"
)
expect(assigns[:stale_branches].map(&:name)).to eq(
["feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'"]
)
end
end
end
end

View File

@ -13,10 +13,12 @@ describe('IssuesListApp component', () => {
dueDate: '2020-12-17',
startDate: '2020-12-10',
title: 'My milestone',
webPath: '/milestone/webPath',
webUrl: '/milestone/webUrl',
},
dueDate: '2020-12-12',
humanTimeEstimate: '1w',
timeStats: {
humanTimeEstimate: '1w',
},
};
const findMilestone = () => wrapper.find('[data-testid="issuable-milestone"]');
@ -54,7 +56,7 @@ describe('IssuesListApp component', () => {
expect(milestone.text()).toBe(issue.milestone.title);
expect(milestone.find(GlIcon).props('name')).toBe('clock');
expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webPath);
expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webUrl);
});
describe.each`
@ -100,7 +102,7 @@ describe('IssuesListApp component', () => {
const timeEstimate = wrapper.find('[data-testid="time-estimate"]');
expect(timeEstimate.text()).toBe(issue.humanTimeEstimate);
expect(timeEstimate.text()).toBe(issue.timeStats.humanTimeEstimate);
expect(timeEstimate.attributes('title')).toBe('Estimate');
expect(timeEstimate.find(GlIcon).props('name')).toBe('timer');
});

View File

@ -1,18 +1,9 @@
import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui';
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import {
filteredTokens,
getIssuesQueryResponse,
locationSearch,
urlParams,
} from 'jest/issues_list/mock_data';
import { apiParams, filteredTokens, locationSearch, urlParams } from 'jest/issues_list/mock_data';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
@ -20,9 +11,13 @@ import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import {
apiSortParams,
CREATED_DESC,
DUE_DATE_OVERDUE,
PAGE_SIZE,
PAGE_SIZE_MANUAL,
PARAM_DUE_DATE,
RELATIVE_POSITION_DESC,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
@ -45,14 +40,12 @@ describe('IssuesListApp component', () => {
let axiosMock;
let wrapper;
const localVue = createLocalVue();
localVue.use(VueApollo);
const defaultProvide = {
autocompleteUsersPath: 'autocomplete/users/path',
calendarPath: 'calendar/path',
canBulkUpdate: false,
emptyStateSvgPath: 'empty-state.svg',
endpoint: 'api/endpoint',
exportCsvPath: 'export/csv/path',
hasBlockedIssuesFeature: true,
hasIssueWeightsFeature: true,
@ -68,6 +61,22 @@ describe('IssuesListApp component', () => {
signInPath: 'sign/in/path',
};
const state = 'opened';
const xPage = 1;
const xTotal = 25;
const tabCounts = {
opened: xTotal,
closed: undefined,
all: undefined,
};
const fetchIssuesResponse = {
data: [],
headers: {
'x-page': xPage,
'x-total': xTotal,
},
};
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail);
const findGlButton = () => wrapper.findComponent(GlButton);
@ -77,26 +86,19 @@ describe('IssuesListApp component', () => {
const findGlLink = () => wrapper.findComponent(GlLink);
const findIssuableList = () => wrapper.findComponent(IssuableList);
const mountComponent = ({
provide = {},
response = getIssuesQueryResponse,
mountFn = shallowMount,
} = {}) => {
const requestHandlers = [[getIssuesQuery, jest.fn().mockResolvedValue(response)]];
const apolloProvider = createMockApollo(requestHandlers);
return mountFn(IssuesListApp, {
localVue,
apolloProvider,
const mountComponent = ({ provide = {}, mountFn = shallowMount } = {}) =>
mountFn(IssuesListApp, {
provide: {
...defaultProvide,
...provide,
},
});
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
axiosMock
.onGet(defaultProvide.endpoint)
.reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
});
afterEach(() => {
@ -106,37 +108,28 @@ describe('IssuesListApp component', () => {
});
describe('IssuableList', () => {
beforeEach(() => {
beforeEach(async () => {
wrapper = mountComponent();
jest.runOnlyPendingTimers();
await waitForPromises();
});
it('renders', () => {
expect(findIssuableList().props()).toMatchObject({
namespace: defaultProvide.projectPath,
recentSearchesStorageKey: 'issues',
searchInputPlaceholder: IssuesListApp.i18n.searchPlaceholder,
searchInputPlaceholder: 'Search or filter results…',
sortOptions: getSortOptions(true, true),
initialSortBy: CREATED_DESC,
issuables: getIssuesQueryResponse.data.project.issues.nodes,
tabs: IssuableListTabs,
currentTab: IssuableStates.Opened,
tabCounts: {
opened: 1,
closed: undefined,
all: undefined,
},
issuablesLoading: false,
isManualOrdering: false,
showBulkEditSidebar: false,
showPaginationControls: true,
currentPage: 1,
previousPage: Number(getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage),
nextPage: Number(getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage),
urlParams: {
sort: urlSortParams[CREATED_DESC],
state: IssuableStates.Opened,
},
tabCounts,
showPaginationControls: false,
issuables: [],
totalItems: xTotal,
currentPage: xPage,
previousPage: xPage - 1,
nextPage: xPage + 1,
urlParams: { page: xPage, state },
});
});
});
@ -164,9 +157,9 @@ describe('IssuesListApp component', () => {
describe('csv import/export component', () => {
describe('when user is signed in', () => {
const search = '?search=refactor&sort=created_date&state=opened';
it('renders', async () => {
const search = '?page=1&search=refactor&state=opened&sort=created_date';
beforeEach(() => {
global.jsdom.reconfigure({ url: `${TEST_HOST}${search}` });
wrapper = mountComponent({
@ -174,13 +167,11 @@ describe('IssuesListApp component', () => {
mountFn: mount,
});
jest.runOnlyPendingTimers();
});
await waitForPromises();
it('renders', () => {
expect(findCsvImportExportButtons().props()).toMatchObject({
exportCsvPath: `${defaultProvide.exportCsvPath}${search}`,
issuableCount: 1,
issuableCount: xTotal,
});
});
});
@ -198,7 +189,7 @@ describe('IssuesListApp component', () => {
it('renders when user has permissions', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount });
expect(findGlButtonAt(2).text()).toBe(IssuesListApp.i18n.editIssues);
expect(findGlButtonAt(2).text()).toBe('Edit issues');
});
it('does not render when user does not have permissions', () => {
@ -224,7 +215,7 @@ describe('IssuesListApp component', () => {
it('renders when user has permissions', () => {
wrapper = mountComponent({ provide: { showNewIssueLink: true }, mountFn: mount });
expect(findGlButtonAt(2).text()).toBe(IssuesListApp.i18n.newIssueLabel);
expect(findGlButtonAt(2).text()).toBe('New issue');
expect(findGlButtonAt(2).attributes('href')).toBe(defaultProvide.newIssuePath);
});
@ -247,6 +238,18 @@ describe('IssuesListApp component', () => {
});
});
describe('page', () => {
it('is set from the url params', () => {
const page = 5;
global.jsdom.reconfigure({ url: setUrlParams({ page }, TEST_HOST) });
wrapper = mountComponent();
expect(findIssuableList().props('currentPage')).toBe(page);
});
});
describe('search', () => {
it('is set from the url params', () => {
global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` });
@ -259,15 +262,13 @@ describe('IssuesListApp component', () => {
describe('sort', () => {
it.each(Object.keys(urlSortParams))('is set as %s from the url params', (sortKey) => {
global.jsdom.reconfigure({
url: setUrlParams({ sort: urlSortParams[sortKey] }, TEST_HOST),
});
global.jsdom.reconfigure({ url: setUrlParams(urlSortParams[sortKey], TEST_HOST) });
wrapper = mountComponent();
expect(findIssuableList().props()).toMatchObject({
initialSortBy: sortKey,
urlParams: { sort: urlSortParams[sortKey] },
urlParams: urlSortParams[sortKey],
});
});
});
@ -325,10 +326,12 @@ describe('IssuesListApp component', () => {
describe('empty states', () => {
describe('when there are issues', () => {
describe('when search returns no results', () => {
beforeEach(() => {
beforeEach(async () => {
global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` });
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
await waitForPromises();
});
it('shows empty state', () => {
@ -341,8 +344,10 @@ describe('IssuesListApp component', () => {
});
describe('when "Open" tab has no issues', () => {
beforeEach(() => {
beforeEach(async () => {
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
await waitForPromises();
});
it('shows empty state', () => {
@ -355,12 +360,14 @@ describe('IssuesListApp component', () => {
});
describe('when "Closed" tab has no issues', () => {
beforeEach(() => {
beforeEach(async () => {
global.jsdom.reconfigure({
url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST),
});
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
await waitForPromises();
});
it('shows empty state', () => {
@ -529,67 +536,74 @@ describe('IssuesListApp component', () => {
describe('events', () => {
describe('when "click-tab" event is emitted by IssuableList', () => {
beforeEach(() => {
axiosMock.onGet(defaultProvide.endpoint).reply(200, fetchIssuesResponse.data, {
'x-page': 2,
'x-total': xTotal,
});
wrapper = mountComponent();
findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
});
it('updates to the new tab', () => {
expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed);
it('makes API call to filter the list by the new state and resets the page to 1', () => {
expect(axiosMock.history.get[1].params).toMatchObject({
page: 1,
state: IssuableStates.Closed,
});
});
});
describe('when "page-change" event is emitted by IssuableList', () => {
beforeEach(() => {
const data = [{ id: 10, title: 'title', state }];
const page = 2;
const totalItems = 21;
beforeEach(async () => {
axiosMock.onGet(defaultProvide.endpoint).reply(200, data, {
'x-page': page,
'x-total': totalItems,
});
wrapper = mountComponent();
findIssuableList().vm.$emit('page-change', 2);
findIssuableList().vm.$emit('page-change', page);
await waitForPromises();
});
it('updates to the new page', () => {
expect(findIssuableList().props('currentPage')).toBe(2);
it('fetches issues with expected params', () => {
expect(axiosMock.history.get[1].params).toMatchObject({
page,
per_page: PAGE_SIZE,
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 },
});
});
});
describe('when "reorder" event is emitted by IssuableList', () => {
const issueOne = {
...getIssuesQueryResponse.data.project.issues.nodes[0],
id: 1,
iid: 101,
title: 'Issue one',
};
const issueTwo = {
...getIssuesQueryResponse.data.project.issues.nodes[0],
id: 2,
iid: 102,
title: 'Issue two',
};
const issueThree = {
...getIssuesQueryResponse.data.project.issues.nodes[0],
id: 3,
iid: 103,
title: 'Issue three',
};
const issueFour = {
...getIssuesQueryResponse.data.project.issues.nodes[0],
id: 4,
iid: 104,
title: 'Issue four',
};
const response = {
data: {
project: {
issues: {
...getIssuesQueryResponse.data.project.issues,
nodes: [issueOne, issueTwo, issueThree, issueFour],
},
},
},
};
const issueOne = { id: 1, iid: 101, title: 'Issue one' };
const issueTwo = { id: 2, iid: 102, title: 'Issue two' };
const issueThree = { id: 3, iid: 103, title: 'Issue three' };
const issueFour = { id: 4, iid: 104, title: 'Issue four' };
const issues = [issueOne, issueTwo, issueThree, issueFour];
beforeEach(() => {
wrapper = mountComponent({ response });
jest.runOnlyPendingTimers();
beforeEach(async () => {
axiosMock.onGet(defaultProvide.endpoint).reply(200, issues, fetchIssuesResponse.headers);
wrapper = mountComponent();
await waitForPromises();
});
describe('when successful', () => {
@ -630,18 +644,21 @@ describe('IssuesListApp component', () => {
});
describe('when "sort" event is emitted by IssuableList', () => {
it.each(Object.keys(urlSortParams))(
'updates to the new sort when payload is `%s`',
it.each(Object.keys(apiSortParams))(
'fetches issues with correct params with payload `%s`',
async (sortKey) => {
wrapper = mountComponent();
findIssuableList().vm.$emit('sort', sortKey);
jest.runOnlyPendingTimers();
await nextTick();
await waitForPromises();
expect(findIssuableList().props('urlParams')).toMatchObject({
sort: urlSortParams[sortKey],
expect(axiosMock.history.get[1].params).toEqual({
page: xPage,
per_page: sortKey === RELATIVE_POSITION_DESC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
state,
with_labels_details: true,
...apiSortParams[sortKey],
});
},
);
@ -651,11 +668,13 @@ describe('IssuesListApp component', () => {
beforeEach(() => {
wrapper = mountComponent();
jest.spyOn(eventHub, '$emit');
findIssuableList().vm.$emit('update-legacy-bulk-edit');
});
it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', () => {
it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', async () => {
findIssuableList().vm.$emit('update-legacy-bulk-edit');
await waitForPromises();
expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
});
});
@ -667,6 +686,10 @@ describe('IssuesListApp component', () => {
findIssuableList().vm.$emit('filter', filteredTokens);
});
it('makes an API call to search for issues with the search term', () => {
expect(axiosMock.history.get[1].params).toMatchObject(apiParams);
});
it('updates IssuableList with url params', () => {
expect(findIssuableList().props('urlParams')).toMatchObject(urlParams);
});

View File

@ -3,76 +3,6 @@ import {
OPERATOR_IS_NOT,
} from '~/vue_shared/components/filtered_search_bar/constants';
export const getIssuesQueryResponse = {
data: {
project: {
issues: {
count: 1,
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: 'startcursor',
endCursor: 'endcursor',
},
nodes: [
{
id: 'gid://gitlab/Issue/123456',
iid: '789',
blockedByCount: 1,
closedAt: null,
confidential: false,
createdAt: '2021-05-22T04:08:01Z',
downvotes: 2,
dueDate: '2021-05-29',
healthStatus: null,
humanTimeEstimate: null,
moved: false,
title: 'Issue title',
updatedAt: '2021-05-22T04:08:01Z',
upvotes: 3,
userDiscussionsCount: 4,
webUrl: 'project/-/issues/789',
weight: 5,
assignees: {
nodes: [
{
id: 'gid://gitlab/User/234',
avatarUrl: 'avatar/url',
name: 'Marge Simpson',
username: 'msimpson',
webUrl: 'url/msimpson',
},
],
},
author: {
id: 'gid://gitlab/User/456',
avatarUrl: 'avatar/url',
name: 'Homer Simpson',
username: 'hsimpson',
webUrl: 'url/hsimpson',
},
labels: {
nodes: [
{
id: 'gid://gitlab/ProjectLabel/456',
color: '#333',
title: 'Label title',
description: 'Label description',
},
],
},
milestone: null,
taskCompletionStatus: {
completedCount: 1,
count: 2,
},
},
],
},
},
},
};
export const locationSearch = [
'?search=find+issues',
'author_username=homer',
@ -89,8 +19,8 @@ export const locationSearch = [
'not[label_name][]=drama',
'my_reaction_emoji=thumbsup',
'confidential=no',
'iteration_id=4',
'not[iteration_id]=20',
'iteration_title=season:+%234',
'not[iteration_title]=season:+%2320',
'epic_id=gitlab-org%3A%3A%2612',
'not[epic_id]=gitlab-org%3A%3A%2634',
'weight=1',
@ -121,8 +51,8 @@ export const filteredTokens = [
{ type: 'labels', value: { data: 'drama', operator: OPERATOR_IS_NOT } },
{ type: 'my_reaction_emoji', value: { data: 'thumbsup', operator: OPERATOR_IS } },
{ type: 'confidential', value: { data: 'no', operator: OPERATOR_IS } },
{ type: 'iteration', value: { data: '4', operator: OPERATOR_IS } },
{ type: 'iteration', value: { data: '20', operator: OPERATOR_IS_NOT } },
{ type: 'iteration', value: { data: 'season: #4', operator: OPERATOR_IS } },
{ type: 'iteration', value: { data: 'season: #20', operator: OPERATOR_IS_NOT } },
{ type: 'epic_id', value: { data: 'gitlab-org::&12', operator: OPERATOR_IS } },
{ type: 'epic_id', value: { data: 'gitlab-org::&34', operator: OPERATOR_IS_NOT } },
{ type: 'weight', value: { data: '1', operator: OPERATOR_IS } },
@ -141,32 +71,30 @@ export const filteredTokensWithSpecialValues = [
];
export const apiParams = {
authorUsername: 'homer',
assigneeUsernames: ['bart', 'lisa'],
milestoneTitle: 'season 4',
labelName: ['cartoon', 'tv'],
myReactionEmoji: 'thumbsup',
author_username: 'homer',
'not[author_username]': 'marge',
assignee_username: ['bart', 'lisa'],
'not[assignee_username]': ['patty', 'selma'],
milestone: 'season 4',
'not[milestone]': 'season 20',
labels: ['cartoon', 'tv'],
'not[labels]': ['live action', 'drama'],
my_reaction_emoji: 'thumbsup',
confidential: 'no',
iterationId: '4',
epicId: 'gitlab-org::&12',
iteration_title: 'season: #4',
'not[iteration_title]': 'season: #20',
epic_id: '12',
'not[epic_id]': 'gitlab-org::&34',
weight: '1',
not: {
authorUsername: 'marge',
assigneeUsernames: ['patty', 'selma'],
milestoneTitle: 'season 20',
labelName: ['live action', 'drama'],
iterationId: '20',
epicId: 'gitlab-org::&34',
weight: '3',
},
'not[weight]': '3',
};
export const apiParamsWithSpecialValues = {
assigneeId: '123',
assigneeUsernames: 'bart',
myReactionEmoji: 'None',
iterationWildcardId: 'CURRENT',
epicId: 'None',
assignee_id: '123',
assignee_username: 'bart',
my_reaction_emoji: 'None',
iteration_id: 'Current',
epic_id: 'None',
weight: 'None',
};
@ -181,8 +109,8 @@ export const urlParams = {
'not[label_name][]': ['live action', 'drama'],
my_reaction_emoji: 'thumbsup',
confidential: 'no',
iteration_id: '4',
'not[iteration_id]': '20',
iteration_title: 'season: #4',
'not[iteration_title]': 'season: #20',
epic_id: 'gitlab-org%3A%3A%2612',
'not[epic_id]': 'gitlab-org::&34',
weight: '1',

View File

@ -8,20 +8,19 @@ import {
urlParams,
urlParamsWithSpecialValues,
} from 'jest/issues_list/mock_data';
import { DUE_DATE_VALUES, urlSortParams } from '~/issues_list/constants';
import { API_PARAM, DUE_DATE_VALUES, URL_PARAM, urlSortParams } from '~/issues_list/constants';
import {
convertToUrlParams,
convertToParams,
convertToSearchQuery,
getDueDateValue,
getFilterTokens,
getSortKey,
getSortOptions,
convertToApiParams,
} from '~/issues_list/utils';
describe('getSortKey', () => {
it.each(Object.keys(urlSortParams))('returns %s given the correct inputs', (sortKey) => {
const sort = urlSortParams[sortKey];
const { sort } = urlSortParams[sortKey];
expect(getSortKey(sort)).toBe(sortKey);
});
});
@ -81,26 +80,31 @@ describe('getFilterTokens', () => {
});
});
describe('convertToApiParams', () => {
describe('convertToParams', () => {
it('returns api params given filtered tokens', () => {
expect(convertToApiParams(filteredTokens)).toEqual(apiParams);
expect(convertToParams(filteredTokens, API_PARAM)).toEqual({
...apiParams,
epic_id: 'gitlab-org::&12',
});
});
it('returns api params given filtered tokens with special values', () => {
expect(convertToApiParams(filteredTokensWithSpecialValues)).toEqual(apiParamsWithSpecialValues);
expect(convertToParams(filteredTokensWithSpecialValues, API_PARAM)).toEqual(
apiParamsWithSpecialValues,
);
});
});
describe('convertToUrlParams', () => {
it('returns url params given filtered tokens', () => {
expect(convertToUrlParams(filteredTokens)).toEqual({
expect(convertToParams(filteredTokens, URL_PARAM)).toEqual({
...urlParams,
epic_id: 'gitlab-org::&12',
});
});
it('returns url params given filtered tokens with special values', () => {
expect(convertToUrlParams(filteredTokensWithSpecialValues)).toEqual(urlParamsWithSpecialValues);
expect(convertToParams(filteredTokensWithSpecialValues, URL_PARAM)).toEqual(
urlParamsWithSpecialValues,
);
});
});

View File

@ -8,6 +8,7 @@ import ReleasesIndexApolloClientApp from '~/releases/components/app_index_apollo
import ReleaseBlock from '~/releases/components/release_block.vue';
import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue';
import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue';
import { PAGE_SIZE } from '~/releases/constants';
import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
@ -29,6 +30,8 @@ describe('app_index_apollo_client.vue', () => {
);
const projectPath = 'project/path';
const newReleasePath = 'path/to/new/release/page';
const before = 'beforeCursor';
const after = 'afterCursor';
let wrapper;
let allReleasesQueryResponse;
@ -64,6 +67,7 @@ describe('app_index_apollo_client.vue', () => {
const findNewReleaseButton = () =>
wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease);
const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock);
const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient);
// Expectations
const expectLoadingIndicator = () => {
@ -119,6 +123,18 @@ describe('app_index_apollo_client.vue', () => {
});
};
const expectPagination = () => {
it('renders the pagination buttons', () => {
expect(findPagination().exists()).toBe(true);
});
};
const expectNoPagination = () => {
it('does not render the pagination buttons', () => {
expect(findPagination().exists()).toBe(false);
});
};
// Tests
describe('when the component is loading data', () => {
beforeEach(() => {
@ -130,6 +146,7 @@ describe('app_index_apollo_client.vue', () => {
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(0);
expectNoPagination();
});
describe('when the data has successfully loaded, but there are no releases', () => {
@ -143,6 +160,7 @@ describe('app_index_apollo_client.vue', () => {
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(0);
expectNoPagination();
});
describe('when an error occurs while loading data', () => {
@ -155,9 +173,10 @@ describe('app_index_apollo_client.vue', () => {
expectFlashMessage();
expectNewReleaseButton();
expectReleases(0);
expectNoPagination();
});
describe('when the data has successfully loaded', () => {
describe('when the data has successfully loaded with a single page of results', () => {
beforeEach(() => {
createComponent();
});
@ -167,12 +186,24 @@ describe('app_index_apollo_client.vue', () => {
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
expectNoPagination();
});
describe('when the data has successfully loaded with multiple pages of results', () => {
beforeEach(() => {
allReleasesQueryResponse.data.project.releases.pageInfo.hasNextPage = true;
createComponent(Promise.resolve(allReleasesQueryResponse));
});
expectNoLoadingIndicator();
expectNoEmptyState();
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
expectPagination();
});
describe('URL parameters', () => {
const before = 'beforeCursor';
const after = 'afterCursor';
describe('when the URL contains no query parameters', () => {
beforeEach(() => {
createComponent();
@ -241,4 +272,30 @@ describe('app_index_apollo_client.vue', () => {
expect(findNewReleaseButton().attributes().href).toBe(newReleasePath);
});
});
describe('pagination', () => {
beforeEach(async () => {
mockQueryParams = { before };
allReleasesQueryResponse.data.project.releases.pageInfo.hasNextPage = true;
createComponent(Promise.resolve(allReleasesQueryResponse));
await wrapper.vm.$nextTick();
});
it('requeries the GraphQL endpoint when a pagination button is clicked', async () => {
expect(allReleasesQueryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]);
mockQueryParams = { after };
findPagination().vm.$emit('next', after);
await wrapper.vm.$nextTick();
expect(allReleasesQueryMock.mock.calls).toEqual([
[expect.objectContaining({ before })],
[expect.objectContaining({ after })],
]);
});
});
});

View File

@ -0,0 +1,126 @@
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { historyPushState } from '~/lib/utils/common_utils';
import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue';
jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'),
historyPushState: jest.fn(),
}));
describe('releases_pagination_apollo_client.vue', () => {
const startCursor = 'startCursor';
const endCursor = 'endCursor';
let wrapper;
let onPrev;
let onNext;
const createComponent = (pageInfo) => {
onPrev = jest.fn();
onNext = jest.fn();
wrapper = mountExtended(ReleasesPaginationApolloClient, {
propsData: {
pageInfo,
},
listeners: {
prev: onPrev,
next: onNext,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const singlePageInfo = {
hasPreviousPage: false,
hasNextPage: false,
startCursor,
endCursor,
};
const onlyNextPageInfo = {
hasPreviousPage: false,
hasNextPage: true,
startCursor,
endCursor,
};
const onlyPrevPageInfo = {
hasPreviousPage: true,
hasNextPage: false,
startCursor,
endCursor,
};
const prevAndNextPageInfo = {
hasPreviousPage: true,
hasNextPage: true,
startCursor,
endCursor,
};
const findPrevButton = () => wrapper.findByTestId('prevButton');
const findNextButton = () => wrapper.findByTestId('nextButton');
describe.each`
description | pageInfo | prevEnabled | nextEnabled
${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false}
${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true}
${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false}
${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true}
`('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => {
describe(description, () => {
beforeEach(() => {
createComponent(pageInfo);
});
it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => {
expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled');
});
it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => {
expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled');
});
});
});
describe('button behavior', () => {
beforeEach(() => {
createComponent(prevAndNextPageInfo);
});
describe('next button behavior', () => {
beforeEach(() => {
findNextButton().trigger('click');
});
it('emits an "next" event with the "after" cursor', () => {
expect(onNext.mock.calls).toEqual([[endCursor]]);
});
it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([
[expect.stringContaining(`?after=${endCursor}`)],
]);
});
});
describe('prev button behavior', () => {
beforeEach(() => {
findPrevButton().trigger('click');
});
it('emits an "prev" event with the "before" cursor', () => {
expect(onPrev.mock.calls).toEqual([[startCursor]]);
});
it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([
[expect.stringContaining(`?before=${startCursor}`)],
]);
});
});
});
});

View File

@ -302,6 +302,7 @@ RSpec.describe IssuesHelper do
email: current_user&.notification_email,
emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'),
empty_state_svg_path: '#',
endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project),
has_project_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: '#',

View File

@ -44,6 +44,30 @@ RSpec.describe Gitlab::Ci::Variables::Collection do
end
end
describe '#compact' do
subject do
described_class.new
.append(key: 'STRING', value: 'string')
.append(key: 'NIL', value: nil)
.append(key: nil, value: 'string')
end
it 'returns a new Collection instance', :aggregate_failures do
collection = subject.compact
expect(collection).to be_an_instance_of(described_class)
expect(collection).not_to eql(subject)
end
it 'rejects pair that has nil value', :aggregate_failures do
collection = subject.compact
expect(collection).not_to include(key: 'NIL', value: nil, public: true)
expect(collection).to include(key: 'STRING', value: 'string', public: true)
expect(collection).to include(key: nil, value: 'string', public: true)
end
end
describe '#concat' do
it 'appends all elements from an array' do
collection = described_class.new([{ key: 'VAR_1', value: '1' }])

View File

@ -0,0 +1,108 @@
# frozen_string_literal: true
require 'fast_spec_helper'
require_relative '../../../../rubocop/cop/usage_data/histogram_with_large_table'
RSpec.describe RuboCop::Cop::UsageData::HistogramWithLargeTable do
let(:high_traffic_models) { %w[Issue Ci::Build] }
let(:msg) { 'Avoid histogram method on' }
let(:config) do
RuboCop::Config.new('UsageData/HistogramWithLargeTable' => {
'HighTrafficModels' => high_traffic_models
})
end
subject(:cop) { described_class.new(config) }
context 'with large tables' do
context 'with one-level constants' do
context 'when calling histogram(Issue)' do
it 'registers an offense' do
expect_offense(<<~CODE)
histogram(Issue, :project_id, buckets: 1..100)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg} Issue
CODE
end
end
context 'when calling histogram(::Issue)' do
it 'registers an offense' do
expect_offense(<<~CODE)
histogram(::Issue, :project_id, buckets: 1..100)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg} Issue
CODE
end
end
context 'when calling histogram(Issue.closed)' do
it 'registers an offense' do
expect_offense(<<~CODE)
histogram(Issue.closed, :project_id, buckets: 1..100)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg} Issue
CODE
end
end
context 'when calling histogram(::Issue.closed)' do
it 'registers an offense' do
expect_offense(<<~CODE)
histogram(::Issue.closed, :project_id, buckets: 1..100)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg} Issue
CODE
end
end
end
context 'with two-level constants' do
context 'when calling histogram(::Ci::Build)' do
it 'registers an offense' do
expect_offense(<<~CODE)
histogram(::Ci::Build, buckets: 1..100)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg} Ci::Build
CODE
end
end
context 'when calling histogram(::Ci::Build.active)' do
it 'registers an offense' do
expect_offense(<<~CODE)
histogram(::Ci::Build.active, buckets: 1..100)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg} Ci::Build
CODE
end
end
context 'when calling histogram(Ci::Build)' do
it 'registers an offense' do
expect_offense(<<~CODE)
histogram(Ci::Build, buckets: 1..100)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg} Ci::Build
CODE
end
end
context 'when calling histogram(Ci::Build.active)' do
it 'registers an offense' do
expect_offense(<<~CODE)
histogram(Ci::Build.active, buckets: 1..100)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg} Ci::Build
CODE
end
end
end
end
context 'with non related class' do
it 'does not register an offense' do
expect_no_offenses('histogram(MergeRequest, buckets: 1..100)')
end
end
context 'with non related method' do
it 'does not register an offense' do
expect_no_offenses('count(Issue, buckets: 1..100)')
end
end
end