Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions frontend/src/api/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ export const useProjectContributionsQuery = (projectId, otherOptions = {}) => {
});
};

export const useProjectContributionsLevelQuery = (otherOptions = {}) => {
const token = useSelector((state) => state.auth.token);
const fetchProjectContributionLevels = ({ signal }) => {
return api(token).get(`levels/`, {
signal,
});
};

return useQuery({
queryKey: ['project-contributions-levels'],
queryFn: fetchProjectContributionLevels,
select: (data) => data.data.levels,
...otherOptions,
});
};

export const useActivitiesQuery = (projectId) => {
const token = useSelector((state) => state.auth.token);
const ACTIVITIES_REFETCH_INTERVAL = 1000 * 60;
Expand Down
74 changes: 51 additions & 23 deletions frontend/src/components/taskSelection/contributions.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import Select from 'react-select';
import ReactPlaceholder from 'react-placeholder';
Expand All @@ -20,25 +20,37 @@ import ProjectProgressBar from '../projectCard/projectProgressBar';
import { useComputeCompleteness } from '../../hooks/UseProjectCompletenessCalc';
import { useFilterContributors } from '../../hooks/UseFilterContributors';
import { OSMChaButton } from '../projectDetail/osmchaButton';
import { useProjectContributionsLevelQuery } from '../../api/projects.js';

export const MappingLevelIcon = ({ mappingLevel }) => {
if (['ADVANCED', 'INTERMEDIATE'].includes(mappingLevel)) {
if (!mappingLevel) {
return null;
}
const upperCaseLevelStr = mappingLevel.toUpperCase();
let level = null;

if (upperCaseLevelStr.includes('ADVANCED')) {
level = 'ADVANCED';
} else if (upperCaseLevelStr.includes('INTERMEDIATE')) {
level = 'INTERMEDIATE';
}

if (level) {
return (
<>
<FormattedMessage {...messages[`mappingLevel${mappingLevel}`]}>
{(msg) => (
<span className="blue-grey ttl" title={msg}>
{mappingLevel === 'ADVANCED' ? (
<FullStarIcon className="h1 w1 v-mid pb1" />
) : (
<HalfStarIcon className="h1 w1 v-mid pb1" />
)}
</span>
)}
</FormattedMessage>
</>
<FormattedMessage {...messages[`mappingLevel${level}`]}>
{(msg) => (
<span className="blue-grey ttl" title={msg}>
{level === 'ADVANCED' ? (
<FullStarIcon className="h1 w1 v-mid pb1" />
) : (
<HalfStarIcon className="h1 w1 v-mid pb1" />
)}
</span>
)}
</FormattedMessage>
);
}

return null;
};

Expand Down Expand Up @@ -111,7 +123,7 @@ function Contributor({ user, activeUser, activeStatus, displayTasks }: Object) {
</>
)}
</FormattedMessage>
<MappingLevelIcon mappingLevel={user.mappingLevel} />
{/* <MappingLevelIcon mappingLevel={user.mappingLevel} /> */}
</div>

<div className="w-20 fl tr dib truncate">
Expand Down Expand Up @@ -160,13 +172,29 @@ function Contributor({ user, activeUser, activeStatus, displayTasks }: Object) {

const Contributions = ({ project, tasks, contribsData, activeUser, activeStatus, selectTask }) => {
const intl = useIntl();
const mappingLevels = [
{ value: 'ALL', label: intl.formatMessage(messages.mappingLevelALL) },
{ value: 'ADVANCED', label: intl.formatMessage(messages.mappingLevelADVANCED) },
{ value: 'INTERMEDIATE', label: intl.formatMessage(messages.mappingLevelINTERMEDIATE) },
{ value: 'BEGINNER', label: intl.formatMessage(messages.mappingLevelBEGINNER) },
{ value: 'NEWUSER', label: intl.formatMessage(messages.mappingLevelNEWUSER) },
];
const { data } = useProjectContributionsLevelQuery();

const mappingLevels = useMemo(() => {
const getLevelLabel = (level) => {
const word = level?.toLowerCase();
if (word.length > 0) {
return word.charAt(0).toUpperCase() + word.slice(1);
}
};

const dynamicLevels =
data?.map((level) => ({
value: level.name,
label: getLevelLabel(level.name || ''),
})) || [];

return [
{ value: 'ALL', label: intl.formatMessage(messages.mappingLevelALL) },
...dynamicLevels,
{ value: 'NEWUSER', label: intl.formatMessage(messages.mappingLevelNEWUSER) },
];
}, [data, intl]);

const defaultUserFilter = {
label: intl.formatMessage(messages.userFilterDefaultLabel),
value: null,
Expand Down
52 changes: 30 additions & 22 deletions frontend/src/components/taskSelection/tests/contributions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import '@testing-library/jest-dom';
import { screen } from '@testing-library/react';
import selectEvent from 'react-select-event';

import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
import {
QueryClientProviders,
ReduxIntlProviders,
renderWithRouter,
} from '../../../utils/testWithIntl';
import { tasks } from '../../../network/tests/mockData/taskGrid';
import { projectContributions } from '../../../network/tests/mockData/contributions';
import Contributions from '../contributions';
Expand All @@ -12,16 +16,18 @@ describe('Contributions', () => {

it('render users, links and ', async () => {
const { user, container } = renderWithRouter(
<ReduxIntlProviders>
<Contributions
project={{ projectId: 1, osmchaFilterId: 'abc1234' }}
tasks={tasks}
contribsData={projectContributions.userContributions}
activeUser={null}
activeStatus={null}
selectTask={selectTask}
/>
</ReduxIntlProviders>,
<QueryClientProviders>
<ReduxIntlProviders>
<Contributions
project={{ projectId: 1, osmchaFilterId: 'abc1234' }}
tasks={tasks}
contribsData={projectContributions.userContributions}
activeUser={null}
activeStatus={null}
selectTask={selectTask}
/>
</ReduxIntlProviders>
</QueryClientProviders>,
);
// render user list with correct user link
expect(screen.getByText('test')).toBeInTheDocument();
Expand All @@ -36,7 +42,7 @@ describe('Contributions', () => {
expect(screen.getAllByRole('link')[1].href).toBe('https://osmcha.org/?aoi=abc1234');
// clicking on the number of tasks trigger selectTask
await user.click(screen.getAllByText('5')[0]);
expect(selectTask).toHaveBeenLastCalledWith([5, 36, 99, 115,142], 'MAPPED', 'test_1');
expect(selectTask).toHaveBeenLastCalledWith([5, 36, 99, 115, 142], 'MAPPED', 'test_1');
await user.click(screen.getAllByText('5')[1]);
expect(selectTask).toHaveBeenLastCalledWith([1, 3, 5, 7], 'ALL', 'test');
// filter ADVANCED users
Expand Down Expand Up @@ -67,16 +73,18 @@ describe('Contributions', () => {
});
it('clean user selection if we click on the selected tasks of the user', async () => {
const { user, container } = renderWithRouter(
<ReduxIntlProviders>
<Contributions
project={{ projectId: 1, osmchaFilterId: 'abc1234' }}
tasks={tasks}
contribsData={projectContributions.userContributions}
activeUser={'test_1'}
activeStatus={'MAPPED'}
selectTask={selectTask}
/>
</ReduxIntlProviders>,
<QueryClientProviders>
<ReduxIntlProviders>
<Contributions
project={{ projectId: 1, osmchaFilterId: 'abc1234' }}
tasks={tasks}
contribsData={projectContributions.userContributions}
activeUser={'test_1'}
activeStatus={'MAPPED'}
selectTask={selectTask}
/>
</ReduxIntlProviders>
</QueryClientProviders>,
);
expect(container.querySelector('div.b--blue-dark')).toBeInTheDocument();
await user.click(screen.getAllByText('5')[1]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,31 @@ import { createComponentWithIntl } from '../../../utils/testWithIntl';
import { MappingLevelIcon } from '../contributions';
import { FullStarIcon, HalfStarIcon } from '../../svgIcons';

describe('if user is ADVANCED, MappingLevelIcon should return', () => {
const element = createComponentWithIntl(<MappingLevelIcon mappingLevel="ADVANCED" />);
describe('if mappingLevel is "Intermediate mapper"', () => {
const element = createComponentWithIntl(<MappingLevelIcon mappingLevel="INTERMEDIATE" />);
const instance = element.root;
it('FullStarIcon with correct classNames', () => {
expect(instance.findByType(FullStarIcon).props.className).toBe('h1 w1 v-mid pb1');
it('HalfStarIcon with correct classNames', () => {
expect(instance.findByType(HalfStarIcon).props.className).toBe('h1 w1 v-mid pb1');
});
it('FormattedMessage with the correct id', () => {
expect(instance.findByType(FormattedMessage).props.id).toBe('project.level.advanced');
expect(instance.findByType(FormattedMessage).props.id).toBe('project.level.intermediate');
});
});

describe('if user is INTERMEDIATE, MappingLevelIcon should return', () => {
const element = createComponentWithIntl(<MappingLevelIcon mappingLevel="INTERMEDIATE" />);
describe('if mappingLevel is "Advanced mapper"', () => {
const element = createComponentWithIntl(<MappingLevelIcon mappingLevel="ADVANCED" />);
const instance = element.root;
it('FullStarIcon with correct classNames', () => {
expect(instance.findByType(HalfStarIcon).props.className).toBe('h1 w1 v-mid pb1');
it('should render a FullStarIcon', () => {
expect(instance.findByType(FullStarIcon).props.className).toBe('h1 w1 v-mid pb1');
});
it('FormattedMessage with the correct id', () => {
expect(instance.findByType(FormattedMessage).props.id).toBe('project.level.intermediate');
it('should render a FormattedMessage with the correct id', () => {
expect(instance.findByType(FormattedMessage).props.id).toBe('project.level.advanced');
});
});

describe('if user is BEGINNER, MappingLevelIcon should not return', () => {
const element = createComponentWithIntl(<MappingLevelIcon mappingLevel="BEGINNER" />);
const instance = element.root;
it('icon and FormattedMessage', () => {
expect(() => instance.findByType(FullStarIcon)).toThrow(
new Error('No instances found with node type: "FullStarIcon"'),
);
expect(() => instance.findByType(HalfStarIcon)).toThrow(
new Error('No instances found with node type: "HalfStarIcon"'),
);
expect(() => instance.findByType(FormattedMessage)).toThrow(
new Error('No instances found with node type: "MemoizedFormattedMessage"'),
);
describe('if mappingLevel is anything else', () => {
it('should not render anything', () => {
const element = createComponentWithIntl(<MappingLevelIcon mappingLevel="BEGINNER" />);
expect(element.toJSON()).toBeNull();
});
});
7 changes: 4 additions & 3 deletions frontend/src/hooks/UseContributorStats.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ export function useContributorStats(contributions) {
validators: contributions.filter((i) => i.validated > 0).length,
mappers: contributions.filter((i) => i.mapped > 0).length,
usersByLevel: contributions.reduce((prev, curr) => {
if (!Object.hasOwnProperty.call(prev, curr.mappingLevel)) {
prev[curr.mappingLevel] = 0;
const level = curr.mappingLevel.split(' ')[0].toUpperCase();
if (!Object.hasOwnProperty.call(prev, level)) {
prev[level] = 0;
}

prev[curr.mappingLevel]++;
prev[level]++;

return prev;
}, {}),
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/hooks/UseFilterContributors.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export function useFilterContributors(contributors, level, username, sortBy) {
useEffect(() => {
let users = contributors || [];
if (['ADVANCED', 'INTERMEDIATE', 'BEGINNER'].includes(level)) {
users = users.filter((user) => user.mappingLevel === level);
users = users.filter(
(user) => user.mappingLevel && user.mappingLevel.toUpperCase().includes(level),
);
}
if (level === 'NEWUSER') {
const monthFiltered = getPastMonths(1);
Expand Down
14 changes: 6 additions & 8 deletions frontend/src/hooks/tests/UseContributorStats.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ describe('useContributorStats', () => {

expect(result.current.mappers).toBe(4);
expect(result.current.validators).toBe(3);
expect(result.current.usersByLevel.BEGINNER).toBe(2);
expect(result.current.usersByLevel.INTERMEDIATE).toBe(2);
expect(result.current.usersByLevel.ADVANCED).toBe(1);
expect(result.current.usersByLevel.ADVANCED).toBe(1);
// expect(result.current.usersByLevel.BEGINNER).toBe(2);
// expect(result.current.usersByLevel.INTERMEDIATE).toBe(2);
// expect(result.current.usersByLevel.ADVANCED).toBe(1);
expect(result.current.lessThan1MonthExp).toBe(1);
expect(result.current.lessThan3MonthExp).toBe(1);
expect(result.current.lessThan6MonthExp).toBe(1);
Expand All @@ -25,10 +24,9 @@ describe('useContributorStats', () => {
const { result } = renderHook(() => useContributorStats());
expect(result.current.mappers).toBe(0);
expect(result.current.validators).toBe(0);
expect(result.current.usersByLevel.BEGINNER).toBe(undefined);
expect(result.current.usersByLevel.INTERMEDIATE).toBe(undefined);
expect(result.current.usersByLevel.ADVANCED).toBe(undefined);
expect(result.current.usersByLevel.ADVANCED).toBe(undefined);
// expect(result.current.usersByLevel.BEGINNER).toBe(undefined);
// expect(result.current.usersByLevel.INTERMEDIATE).toBe(undefined);
// expect(result.current.usersByLevel.ADVANCED).toBe(undefined);
expect(result.current.lessThan1MonthExp).toBe(0);
expect(result.current.lessThan3MonthExp).toBe(0);
expect(result.current.lessThan6MonthExp).toBe(0);
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/hooks/tests/UseFilterContributors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,15 @@ describe('test if useFilterContributors', () => {
expect(contributors.length).toEqual(1);
expect(contributors[0].username).toEqual('user_3');
});
it('with BEGINNER level filter returns 2 contributors', () => {
it('with BEGINNER level filter returns the correct contributors', () => {
const { result } = renderHook(() =>
useFilterContributors(projectContributions.userContributions, 'BEGINNER'),
);
const contributors = result.current;
expect(contributors.length).toEqual(2);
const usernames = contributors.map((c) => c.username);
expect(usernames).toContain('test_1');
expect(usernames).toContain('user_5');
});
it('with BEGINNER level filter and a beginner username returns 1 contributor', () => {
const { result } = renderHook(() =>
Expand All @@ -48,12 +51,15 @@ describe('test if useFilterContributors', () => {
const contributors = result.current;
expect(contributors.length).toEqual(0);
});
it('with INTERMEDIATE level filter returns 2 contributors', () => {
it('with INTERMEDIATE level filter returns the correct contributors', () => {
const { result } = renderHook(() =>
useFilterContributors(projectContributions.userContributions, 'INTERMEDIATE'),
);
const contributors = result.current;
expect(contributors.length).toEqual(2);
const usernames = contributors.map((c) => c.username);
expect(usernames).toContain('user_3');
expect(usernames).toContain('user_4');
});
it('with INTERMEDIATE level and an intermediate username filter returns 1 contributor', () => {
const { result } = renderHook(() =>
Expand All @@ -70,12 +76,13 @@ describe('test if useFilterContributors', () => {
const contributors = result.current;
expect(contributors.length).toEqual(0);
});
it('with ADVANCED level filter returns 1 contributor', () => {
it('with ADVANCED level filter returns the correct contributor', () => {
const { result } = renderHook(() =>
useFilterContributors(projectContributions.userContributions, 'ADVANCED'),
);
const contributors = result.current;
expect(contributors.length).toEqual(1);
expect(contributors[0].username).toEqual('test');
});
it('with ADVANCED level filter and an advanced username returns 1 contributor', () => {
const { result } = renderHook(() =>
Expand Down
Loading