Commit 13a03512 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch '322755-add-due_date-to-issues-refactor' into 'master'

Add due_date and multiple assignees to issues page refactor

See merge request gitlab-org/gitlab!61731
parents 9be1339e ae385098
......@@ -16,22 +16,25 @@ 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_DESC,
UPDATED_DESC,
URL_PARAM,
urlSortParams,
} from '~/issues_list/constants';
import {
convertToApiParams,
convertToParams,
convertToSearchQuery,
convertToUrlParams,
getDueDateValue,
getFilterTokens,
getSortKey,
getSortOptions,
......@@ -113,6 +116,9 @@ export default {
hasIssueWeightsFeature: {
default: false,
},
hasMultipleIssueAssigneesFeature: {
default: false,
},
initialEmail: {
default: '',
},
......@@ -155,6 +161,7 @@ export default {
const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
return {
dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)),
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
filterTokens: getFilterTokens(window.location.search),
isLoading: false,
......@@ -177,10 +184,10 @@ export default {
return this.state === IssuableStates.Opened;
},
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;
......@@ -203,7 +210,7 @@ export default {
icon: 'user',
token: AuthorToken,
dataType: 'user',
unique: true,
unique: !this.hasMultipleIssueAssigneesFeature,
defaultAuthors: DEFAULT_NONE_ANY,
fetchAuthors: this.fetchUsers,
},
......@@ -298,6 +305,7 @@ export default {
},
urlParams() {
return {
due_date: this.dueDateFilter,
page: this.page,
search: this.searchQuery,
state: this.state,
......@@ -366,6 +374,7 @@ export default {
return axios
.get(this.endpoint, {
params: {
due_date: this.dueDateFilter,
page: this.page,
per_page: PAGE_SIZE,
search: this.searchQuery,
......
......@@ -100,10 +100,26 @@ 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';
export const DUE_DATE_NONE = '0';
export const DUE_DATE_ANY = '';
export const DUE_DATE_OVERDUE = 'overdue';
export const DUE_DATE_WEEK = 'week';
export const DUE_DATE_MONTH = 'month';
export const DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS = 'next_month_and_previous_two_weeks';
export const DUE_DATE_VALUES = [
DUE_DATE_NONE,
DUE_DATE_ANY,
DUE_DATE_OVERDUE,
DUE_DATE_WEEK,
DUE_DATE_MONTH,
DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS,
];
export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC';
export const CREATED_ASC = 'CREATED_ASC';
export const CREATED_DESC = 'CREATED_DESC';
......@@ -258,13 +274,16 @@ export const urlSortParams = {
export const MAX_LIST_SIZE = 10;
export const API_PARAM = 'apiParam';
export const URL_PARAM = 'urlParam';
export const NORMAL_FILTER = 'normalFilter';
export const SPECIAL_FILTER = 'specialFilter';
export const ALTERNATIVE_FILTER = 'alternativeFilter';
export const SPECIAL_FILTER_VALUES = [FILTER_NONE, FILTER_ANY, FILTER_CURRENT];
export const filters = {
author_username: {
apiParam: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'author_username',
},
......@@ -272,7 +291,7 @@ export const filters = {
[NORMAL_FILTER]: 'not[author_username]',
},
},
urlParam: {
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'author_username',
},
......@@ -282,7 +301,7 @@ export const filters = {
},
},
assignee_username: {
apiParam: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'assignee_username',
[SPECIAL_FILTER]: 'assignee_id',
......@@ -291,10 +310,11 @@ export const filters = {
[NORMAL_FILTER]: 'not[assignee_username]',
},
},
urlParam: {
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'assignee_username[]',
[SPECIAL_FILTER]: 'assignee_id',
[ALTERNATIVE_FILTER]: 'assignee_username',
},
[OPERATOR_IS_NOT]: {
[NORMAL_FILTER]: 'not[assignee_username][]',
......@@ -302,7 +322,7 @@ export const filters = {
},
},
milestone: {
apiParam: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'milestone',
},
......@@ -310,7 +330,7 @@ export const filters = {
[NORMAL_FILTER]: 'not[milestone]',
},
},
urlParam: {
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'milestone_title',
},
......@@ -320,7 +340,7 @@ export const filters = {
},
},
labels: {
apiParam: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'labels',
},
......@@ -328,7 +348,7 @@ export const filters = {
[NORMAL_FILTER]: 'not[labels]',
},
},
urlParam: {
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'label_name[]',
},
......@@ -338,13 +358,13 @@ export const filters = {
},
},
my_reaction_emoji: {
apiParam: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'my_reaction_emoji',
[SPECIAL_FILTER]: 'my_reaction_emoji',
},
},
urlParam: {
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'my_reaction_emoji',
[SPECIAL_FILTER]: 'my_reaction_emoji',
......@@ -352,19 +372,19 @@ export const filters = {
},
},
confidential: {
apiParam: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'confidential',
},
},
urlParam: {
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'confidential',
},
},
},
iteration: {
apiParam: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'iteration_title',
[SPECIAL_FILTER]: 'iteration_id',
......@@ -373,7 +393,7 @@ export const filters = {
[NORMAL_FILTER]: 'not[iteration_title]',
},
},
urlParam: {
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'iteration_title',
[SPECIAL_FILTER]: 'iteration_id',
......@@ -384,7 +404,7 @@ export const filters = {
},
},
epic_id: {
apiParam: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'epic_id',
[SPECIAL_FILTER]: 'epic_id',
......@@ -393,7 +413,7 @@ export const filters = {
[NORMAL_FILTER]: 'not[epic_id]',
},
},
urlParam: {
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'epic_id',
[SPECIAL_FILTER]: 'epic_id',
......@@ -404,7 +424,7 @@ export const filters = {
},
},
weight: {
apiParam: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'weight',
[SPECIAL_FILTER]: 'weight',
......@@ -413,7 +433,7 @@ export const filters = {
[NORMAL_FILTER]: 'not[weight]',
},
},
urlParam: {
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'weight',
[SPECIAL_FILTER]: 'weight',
......
......@@ -90,6 +90,7 @@ export function mountIssuesListApp() {
hasIssuableHealthStatusFeature,
hasIssues,
hasIssueWeightsFeature,
hasMultipleIssueAssigneesFeature,
importCsvIssuesPath,
initialEmail,
isSignedIn,
......@@ -127,6 +128,7 @@ export function mountIssuesListApp() {
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssues: parseBoolean(hasIssues),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature),
isSignedIn: parseBoolean(isSignedIn),
issuesPath,
jiraIntegrationPath,
......
......@@ -4,6 +4,7 @@ import {
CREATED_DESC,
DUE_DATE_ASC,
DUE_DATE_DESC,
DUE_DATE_VALUES,
filters,
LABEL_PRIORITY_DESC,
MILESTONE_DUE_ASC,
......@@ -21,12 +22,15 @@ import {
WEIGHT_ASC,
WEIGHT_DESC,
} from '~/issues_list/constants';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
export const getSortKey = (sort) =>
Object.keys(urlSortParams).find((key) => urlSortParams[key].sort === sort);
export const getDueDateValue = (value) => (DUE_DATE_VALUES.includes(value) ? value : undefined);
export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) => {
const sortOptions = [
{
......@@ -167,28 +171,20 @@ export const getFilterTokens = (locationSearch) => {
return filterTokens.concat(searchTokens);
};
const getFilterType = (data) =>
SPECIAL_FILTER_VALUES.includes(data) ? SPECIAL_FILTER : NORMAL_FILTER;
export const convertToApiParams = (filterTokens) =>
filterTokens
.filter((token) => token.type !== FILTERED_SEARCH_TERM)
.reduce((acc, token) => {
const filterType = getFilterType(token.value.data);
const apiParam = filters[token.type].apiParam[token.value.operator][filterType];
return Object.assign(acc, {
[apiParam]: acc[apiParam] ? `${acc[apiParam]},${token.value.data}` : token.value.data,
});
}, {});
const getFilterType = (data, tokenType = '') =>
SPECIAL_FILTER_VALUES.includes(data) ||
(tokenType === 'assignee_username' && isPositiveInteger(data))
? SPECIAL_FILTER
: NORMAL_FILTER;
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);
const urlParam = filters[token.type].urlParam[token.value.operator]?.[filterType];
const filterType = getFilterType(token.value.data, token.type);
const param = filters[token.type][paramType][token.value.operator]?.[filterType];
return Object.assign(acc, {
[urlParam]: acc[urlParam] ? acc[urlParam].concat(token.value.data) : [token.value.data],
[param]: acc[param] ? [acc[param], token.value.data].flat() : token.value.data,
});
}, {});
......
......@@ -171,3 +171,13 @@ export const formattedChangeInPercent = (firstY, lastY, { nonFiniteResult = '-'
export const isNumeric = (value) => {
return !Number.isNaN(parseInt(value, 10));
};
const numberRegex = /^[0-9]+$/;
/**
* Checks whether the value is a positive number or 0, or a string with equivalent value
*
* @param value
* @return {boolean}
*/
export const isPositiveInteger = (value) => numberRegex.test(value);
......@@ -47,7 +47,8 @@ module EE
data = super.merge!(
has_blocked_issues_feature: project.feature_available?(:blocked_issues).to_s,
has_issuable_health_status_feature: project.feature_available?(:issuable_health_status).to_s,
has_issue_weights_feature: project.feature_available?(:issue_weights).to_s
has_issue_weights_feature: project.feature_available?(:issue_weights).to_s,
has_multiple_issue_assignees_feature: project.feature_available?(:multiple_issue_assignees).to_s
)
if project.feature_available?(:epics) && project.group
......
......@@ -137,7 +137,7 @@ RSpec.describe EE::IssuesHelper do
context 'when features are enabled' do
before do
stub_licensed_features(epics: true, iterations: true, issue_weights: true, issuable_health_status: true, blocked_issues: true)
stub_licensed_features(epics: true, iterations: true, issue_weights: true, issuable_health_status: true, blocked_issues: true, multiple_issue_assignees: true)
end
it 'returns data with licensed features enabled' do
......@@ -145,6 +145,7 @@ RSpec.describe EE::IssuesHelper do
has_blocked_issues_feature: 'true',
has_issuable_health_status_feature: 'true',
has_issue_weights_feature: 'true',
has_multiple_issue_assignees_feature: 'true',
group_epics_path: group_epics_path(project.group, format: :json),
project_iterations_path: api_v4_projects_iterations_path(id: project.id)
}
......@@ -163,17 +164,19 @@ RSpec.describe EE::IssuesHelper do
context 'when features are disabled' do
before do
stub_licensed_features(epics: false, iterations: false, issue_weights: false, issuable_health_status: false, blocked_issues: false)
stub_licensed_features(epics: false, iterations: false, issue_weights: false, issuable_health_status: false, blocked_issues: false, multiple_issue_assignees: false)
end
it 'returns data with licensed features disabled' do
expected = {
has_blocked_issues_feature: 'false',
has_issuable_health_status_feature: 'false',
has_issue_weights_feature: 'false'
has_issue_weights_feature: 'false',
has_multiple_issue_assignees_feature: 'false'
}
result = helper.issues_list_data(project, current_user, finder)
expect(result).to include(expected)
expect(result).not_to include(:group_epics_path)
expect(result).not_to include(:project_iterations_path)
......
......@@ -13,8 +13,10 @@ 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,
urlSortParams,
} from '~/issues_list/constants';
......@@ -217,6 +219,16 @@ describe('IssuesListApp component', () => {
});
describe('initial url params', () => {
describe('due_date', () => {
it('is set from the url params', () => {
global.jsdom.reconfigure({ url: `${TEST_HOST}?${PARAM_DUE_DATE}=${DUE_DATE_OVERDUE}` });
wrapper = mountComponent();
expect(findIssuableList().props('urlParams')).toMatchObject({ due_date: DUE_DATE_OVERDUE });
});
});
describe('page', () => {
it('is set from the url params', () => {
const page = 5;
......
......@@ -8,7 +8,9 @@ export const locationSearch = [
'author_username=homer',
'not[author_username]=marge',
'assignee_username[]=bart',
'not[assignee_username][]=lisa',
'assignee_username[]=lisa',
'not[assignee_username][]=patty',
'not[assignee_username][]=selma',
'milestone_title=season+4',
'not[milestone_title]=season+20',
'label_name[]=cartoon',
......@@ -26,7 +28,8 @@ export const locationSearch = [
].join('&');
export const locationSearchWithSpecialValues = [
'assignee_id=None',
'assignee_id=123',
'assignee_username=bart',
'my_reaction_emoji=None',
'iteration_id=Current',
'epic_id=None',
......@@ -37,7 +40,9 @@ export const filteredTokens = [
{ type: 'author_username', value: { data: 'homer', operator: OPERATOR_IS } },
{ type: 'author_username', value: { data: 'marge', operator: OPERATOR_IS_NOT } },
{ type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } },
{ type: 'assignee_username', value: { data: 'lisa', operator: OPERATOR_IS_NOT } },
{ type: 'assignee_username', value: { data: 'lisa', operator: OPERATOR_IS } },
{ type: 'assignee_username', value: { data: 'patty', operator: OPERATOR_IS_NOT } },
{ type: 'assignee_username', value: { data: 'selma', operator: OPERATOR_IS_NOT } },
{ type: 'milestone', value: { data: 'season 4', operator: OPERATOR_IS } },
{ type: 'milestone', value: { data: 'season 20', operator: OPERATOR_IS_NOT } },
{ type: 'labels', value: { data: 'cartoon', operator: OPERATOR_IS } },
......@@ -57,7 +62,8 @@ export const filteredTokens = [
];
export const filteredTokensWithSpecialValues = [
{ type: 'assignee_username', value: { data: 'None', operator: OPERATOR_IS } },
{ type: 'assignee_username', value: { data: '123', operator: OPERATOR_IS } },
{ type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } },
{ type: 'my_reaction_emoji', value: { data: 'None', operator: OPERATOR_IS } },
{ type: 'iteration', value: { data: 'Current', operator: OPERATOR_IS } },
{ type: 'epic_id', value: { data: 'None', operator: OPERATOR_IS } },
......@@ -67,12 +73,12 @@ export const filteredTokensWithSpecialValues = [
export const apiParams = {
author_username: 'homer',
'not[author_username]': 'marge',
assignee_username: 'bart',
'not[assignee_username]': 'lisa',
assignee_username: ['bart', 'lisa'],
'not[assignee_username]': ['patty', 'selma'],
milestone: 'season 4',
'not[milestone]': 'season 20',
labels: 'cartoon,tv',
'not[labels]': 'live action,drama',
labels: ['cartoon', 'tv'],
'not[labels]': ['live action', 'drama'],
my_reaction_emoji: 'thumbsup',
confidential: 'no',
iteration_title: 'season: #4',
......@@ -84,7 +90,8 @@ export const apiParams = {
};
export const apiParamsWithSpecialValues = {
assignee_id: 'None',
assignee_id: '123',
assignee_username: 'bart',
my_reaction_emoji: 'None',
iteration_id: 'Current',
epic_id: 'None',
......@@ -92,28 +99,29 @@ export const apiParamsWithSpecialValues = {
};
export const urlParams = {
author_username: ['homer'],
'not[author_username]': ['marge'],
'assignee_username[]': ['bart'],
'not[assignee_username][]': ['lisa'],
milestone_title: ['season 4'],
'not[milestone_title]': ['season 20'],
author_username: 'homer',
'not[author_username]': 'marge',
'assignee_username[]': ['bart', 'lisa'],
'not[assignee_username][]': ['patty', 'selma'],
milestone_title: 'season 4',
'not[milestone_title]': 'season 20',
'label_name[]': ['cartoon', 'tv'],
'not[label_name][]': ['live action', 'drama'],
my_reaction_emoji: ['thumbsup'],
confidential: ['no'],
iteration_title: ['season: #4'],
'not[iteration_title]': ['season: #20'],
epic_id: ['12'],
'not[epic_id]': ['34'],
weight: ['1'],
'not[weight]': ['3'],
my_reaction_emoji: 'thumbsup',
confidential: 'no',
iteration_title: 'season: #4',
'not[iteration_title]': 'season: #20',
epic_id: '12',
'not[epic_id]': '34',
weight: '1',
'not[weight]': '3',
};
export const urlParamsWithSpecialValues = {
assignee_id: ['None'],
my_reaction_emoji: ['None'],
iteration_id: ['Current'],
epic_id: ['None'],
weight: ['None'],
assignee_id: '123',
'assignee_username[]': 'bart',
my_reaction_emoji: 'None',
iteration_id: 'Current',
epic_id: 'None',
weight: 'None',
};
......@@ -8,11 +8,11 @@ import {
urlParams,
urlParamsWithSpecialValues,
} from 'jest/issues_list/mock_data';
import { urlSortParams } from '~/issues_list/constants';
import { API_PARAM, DUE_DATE_VALUES, URL_PARAM, urlSortParams } from '~/issues_list/constants';
import {
convertToApiParams,
convertToParams,
convertToSearchQuery,
convertToUrlParams,
getDueDateValue,
getFilterTokens,
getSortKey,
getSortOptions,
......@@ -25,6 +25,16 @@ describe('getSortKey', () => {
});
});
describe('getDueDateValue', () => {
it.each(DUE_DATE_VALUES)('returns the argument when it is `%s`', (value) => {
expect(getDueDateValue(value)).toBe(value);
});
it('returns undefined when the argument is invalid', () => {
expect(getDueDateValue('invalid value')).toBeUndefined();
});
});
describe('getSortOptions', () => {
describe.each`
hasIssueWeightsFeature | hasBlockedIssuesFeature | length | containsWeight | containsBlocking
......@@ -70,23 +80,25 @@ 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);
});
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(urlParams);
expect(convertToParams(filteredTokens, URL_PARAM)).toEqual(urlParams);
});
it('returns url params given filtered tokens with special values', () => {
expect(convertToUrlParams(filteredTokensWithSpecialValues)).toEqual(urlParamsWithSpecialValues);
expect(convertToParams(filteredTokensWithSpecialValues, URL_PARAM)).toEqual(
urlParamsWithSpecialValues,
);
});
});
......
......@@ -10,6 +10,7 @@ import {
changeInPercent,
formattedChangeInPercent,
isNumeric,
isPositiveInteger,
} from '~/lib/utils/number_utils';
describe('Number Utils', () => {
......@@ -184,4 +185,29 @@ describe('Number Utils', () => {
expect(isNumeric(value)).toBe(outcome);
});
});
describe.each`
value | outcome
${0} | ${true}
${'0'} | ${true}
${12345} | ${true}
${'12345'} | ${true}
${-1} | ${false}
${'-1'} | ${false}
${1.01} | ${false}
${'1.01'} | ${false}
${'abcd'} | ${false}
${'100abcd'} | ${false}
${'abcd100'} | ${false}
${''} | ${false}
${false} | ${false}
${true} | ${false}
${undefined} | ${false}
${null} | ${false}
${Infinity} | ${false}
`('isPositiveInteger', ({ value, outcome }) => {
it(`when called with ${typeof value} ${value} it returns ${outcome}`, () => {
expect(isPositiveInteger(value)).toBe(outcome);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment