diff --git a/frontend/package.json b/frontend/package.json index 3cf9744da0..0b709dfa9d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@webscopeio/react-textarea-autocomplete": "^4.7.3", "axios": "^0.21.1", "chart.js": "^2.9.4", + "date-fns": "^2.16.1", "dompurify": "^2.2.6", "downshift-hooks": "^0.8.1", "final-form": "^4.20.1", diff --git a/frontend/src/App.js b/frontend/src/App.js index bde8b40c57..f1106da8cc 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -30,12 +30,8 @@ import { Login } from './views/login'; import { Welcome } from './views/welcome'; import { Settings } from './views/settings'; import { ManagementPageIndex, ManagementSection } from './views/management'; -import { - ListOrganisations, - CreateOrganisation, - EditOrganisation, - OrganisationStats, -} from './views/organisations'; +import { ListOrganisations, CreateOrganisation, EditOrganisation } from './views/organisations'; +import { OrganisationStats } from './views/organisationStats'; import { MyTeams, ManageTeams, CreateTeam, EditTeam, TeamDetail } from './views/teams'; import { ListCampaigns, CreateCampaign, EditCampaign } from './views/campaigns'; import { ListInterests, CreateInterest, EditInterest } from './views/interests'; diff --git a/frontend/src/components/projectDetail/timeline.js b/frontend/src/components/projectDetail/timeline.js index 1271f930fb..709c96328a 100644 --- a/frontend/src/components/projectDetail/timeline.js +++ b/frontend/src/components/projectDetail/timeline.js @@ -12,7 +12,9 @@ export default function ProjectTimeline({ tasksByDay }: Object) { data={formatTimelineData(tasksByDay, CHART_COLOURS.orange, CHART_COLOURS.red)} options={{ legend: { position: 'top', align: 'end', labels: { boxWidth: 12 } }, - tooltips: { callbacks: { label: (tooltip, data) => formatTimelineTooltip(tooltip, data) } }, + tooltips: { + callbacks: { label: (tooltip, data) => formatTimelineTooltip(tooltip, data, true) }, + }, scales: { xAxes: [{ type: 'time', time: { unit: unit } }] }, }} /> diff --git a/frontend/src/components/projects/filterSelectFields.js b/frontend/src/components/projects/filterSelectFields.js index dfbfea1b8e..06f5d1ac87 100644 --- a/frontend/src/components/projects/filterSelectFields.js +++ b/frontend/src/components/projects/filterSelectFields.js @@ -2,18 +2,29 @@ import React from 'react'; import ReactPlaceholder from 'react-placeholder'; import 'react-placeholder/lib/reactPlaceholder.css'; import Select from 'react-select'; +import { format, parse } from 'date-fns'; +import DatePicker from 'react-datepicker'; +import { FormattedMessage, useIntl } from 'react-intl'; -import { FormattedMessage } from 'react-intl'; import messages from './messages'; +import { CalendarIcon } from '../svgIcons'; -export const ProjectFilterSelect = (props) => { - const state = props.options; - const fieldsetTitle = ; - const fieldsetTitlePlural = ; +export const ProjectFilterSelect = ({ + fieldsetName, + fieldsetStyle, + titleStyle, + selectedTag, + setQueryForChild, + allQueryParamsForChild, + options, +}) => { + const state = options; + const fieldsetTitle = ; + const fieldsetTitlePlural = ; return ( -
- {fieldsetTitle} +
+ {fieldsetTitle} {state.isError ? (
{
); }; +export const DateFilterPicker = ({ + fieldsetName, + fieldsetStyle, + titleStyle, + selectedValue, + setQueryForChild, + allQueryParamsForChild, +}) => { + const intl = useIntl(); + const dateFormat = 'yyyy-MM-dd'; + return ( +
+ + + + + + setQueryForChild( + { + ...allQueryParamsForChild, + page: undefined, + [fieldsetName]: date ? format(date, dateFormat) : null, + }, + 'pushIn', + ) + } + dateFormat={dateFormat} + className="w-auto pv2 ph1" + placeholderText={intl.formatMessage(messages[`${fieldsetName}Placeholder`])} + showYearDropdown + scrollableYearDropdown + /> +
+ ); +}; + /* defaultSelectedItem gets appended to top of list as an option for reset */ diff --git a/frontend/src/components/projects/messages.js b/frontend/src/components/projects/messages.js index 1e1919ccf8..90f3d7da32 100644 --- a/frontend/src/components/projects/messages.js +++ b/frontend/src/components/projects/messages.js @@ -20,6 +20,22 @@ export default defineMessages({ id: 'project.nav.campaign', defaultMessage: 'Campaign', }, + startDate: { + id: 'navFilters.startDate', + defaultMessage: 'From', + }, + startDatePlaceholder: { + id: 'navFilters.startDate.placeholder', + defaultMessage: 'Click to select a start date', + }, + endDate: { + id: 'navFilters.endDate', + defaultMessage: 'To', + }, + endDatePlaceholder: { + id: 'navFilters.endDatePlace.placeholder', + defaultMessage: 'Click to select an end date', + }, showMapToggle: { id: 'project.nav.showMapToggle', defaultMessage: 'Show map', diff --git a/frontend/src/components/svgIcons/calendar.js b/frontend/src/components/svgIcons/calendar.js new file mode 100644 index 0000000000..87fa5a53d3 --- /dev/null +++ b/frontend/src/components/svgIcons/calendar.js @@ -0,0 +1,16 @@ +import React from 'react'; + +// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/ +// License: CC-By 4.0 +export class CalendarIcon extends React.PureComponent { + render() { + return ( + + + + ); + } +} diff --git a/frontend/src/components/svgIcons/index.js b/frontend/src/components/svgIcons/index.js index 1afbac63d7..fe8355feda 100644 --- a/frontend/src/components/svgIcons/index.js +++ b/frontend/src/components/svgIcons/index.js @@ -77,3 +77,4 @@ export { CircleIcon } from './circle'; export { FourCellsGridIcon, NineCellsGridIcon } from './grid'; export { CutIcon } from './cut'; export { FileImportIcon } from './fileImport'; +export { CalendarIcon } from './calendar'; diff --git a/frontend/src/components/teamsAndOrgs/messages.js b/frontend/src/components/teamsAndOrgs/messages.js index d342bd7c7a..35f442b72c 100644 --- a/frontend/src/components/teamsAndOrgs/messages.js +++ b/frontend/src/components/teamsAndOrgs/messages.js @@ -152,14 +152,26 @@ export default defineMessages({ id: 'management.organisations.stats.to_be_mapped', defaultMessage: 'Tasks to be mapped', }, + tasksMapped: { + id: 'management.organisations.stats.tasks_mapped', + defaultMessage: 'Tasks mapped', + }, readyForValidation: { id: 'management.organisations.stats.ready_for_validation', defaultMessage: 'Ready for validation', }, + tasksValidated: { + id: 'management.organisations.stats.tasks_validated', + defaultMessage: 'Tasks validated', + }, actionsNeeded: { id: 'management.organisations.stats.actions_needed', defaultMessage: 'Actions needed', }, + completedActions: { + id: 'management.organisations.stats.completed_actions', + defaultMessage: 'Completed actions', + }, actionsNeededHelp: { id: 'management.organisations.stats.actions_needed.help', defaultMessage: diff --git a/frontend/src/components/teamsAndOrgs/tasksStats.js b/frontend/src/components/teamsAndOrgs/tasksStats.js new file mode 100644 index 0000000000..b58c416009 --- /dev/null +++ b/frontend/src/components/teamsAndOrgs/tasksStats.js @@ -0,0 +1,104 @@ +import React from 'react'; +import { Bar } from 'react-chartjs-2'; + +import { CHART_COLOURS } from '../../config'; +import { useTagAPI } from '../../hooks/UseTagAPI'; +import { formatFilterCountriesData } from '../../utils/countries'; +import { formatTasksStatsData, formatTimelineTooltip } from '../../utils/formatChartJSData'; +import { ProjectFilterSelect, DateFilterPicker } from '../projects/filterSelectFields'; +import { TasksStatsSummary } from './tasksStatsSummary'; + +const TasksStats = ({ query, setQuery, stats }) => { + const [campaignAPIState] = useTagAPI([], 'campaigns'); + const [countriesAPIState] = useTagAPI([], 'countries', formatFilterCountriesData); + const { + startDate: startDateInQuery, + endDate: endDateInQuery, + campaign: campaignInQuery, + location: countryInQuery, + } = query; + + const fieldsetStyle = 'bn dib pv0-ns pv2 ph2-ns ph1 mh0 mb1'; + const titleStyle = 'dib ttu fw5 blue-grey mb1'; + + return ( + <> +
+ + +
+ + +
+
+
+ +
+
+ +
+ + ); +}; + +const TasksStatsChart = ({ stats }) => { + const options = { + legend: { position: 'top', align: 'end', labels: { boxWidth: 12 } }, + tooltips: { + callbacks: { label: (tooltip, data) => formatTimelineTooltip(tooltip, data, false) }, + }, + scales: { + yAxes: [ + { + stacked: true, + ticks: { + beginAtZero: true, + }, + }, + ], + xAxes: [ + { + stacked: true, + }, + ], + }, + }; + return ( + + ); +}; + +export default TasksStats; diff --git a/frontend/src/components/teamsAndOrgs/tasksStatsSummary.js b/frontend/src/components/teamsAndOrgs/tasksStatsSummary.js new file mode 100644 index 0000000000..bdb132a234 --- /dev/null +++ b/frontend/src/components/teamsAndOrgs/tasksStatsSummary.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { FormattedMessage, FormattedNumber } from 'react-intl'; + +import messages from './messages'; +import { StatsCardContent } from '../statsCardContent'; +import { useTotalTasksStats } from '../../hooks/UseTotalTasksStats'; + +export function TasksStatsSummary({ stats }) { + const totalStats = useTotalTasksStats(stats); + return ( + <> +
+
+ } + className="tc" + value={} + /> +
+
+
+
+ } + className="tc" + value={} + /> +
+
+
+
+ } + className="tc" + value={} + /> +
+
+ + ); +} diff --git a/frontend/src/components/teamsAndOrgs/tests/tasksStats.test.js b/frontend/src/components/teamsAndOrgs/tests/tasksStats.test.js new file mode 100644 index 0000000000..238aad2ce2 --- /dev/null +++ b/frontend/src/components/teamsAndOrgs/tests/tasksStats.test.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { ReduxIntlProviders } from '../../../utils/testWithIntl'; +import { tasksStats } from '../../../network/tests/mockData/tasksStats'; +import TasksStats from '../tasksStats'; + +jest.mock('react-chartjs-2', () => ({ + Bar: () => null, +})); + +describe('TasksStats', () => { + const setQuery = jest.fn(); + it('render basic elements', () => { + render( + + + , + ); + expect(screen.getByText('From')).toBeInTheDocument(); + expect(screen.getByText('To')).toBeInTheDocument(); + expect(screen.getByText('Campaign')).toBeInTheDocument(); + expect(screen.getByText('Location')).toBeInTheDocument(); + expect(screen.getByText('165')).toBeInTheDocument(); + expect(screen.getByText('Tasks mapped')).toBeInTheDocument(); + expect(screen.getByText('46')).toBeInTheDocument(); + expect(screen.getByText('Tasks validated')).toBeInTheDocument(); + expect(screen.getByText('211')).toBeInTheDocument(); + expect(screen.getByText('Completed actions')).toBeInTheDocument(); + }); + it('load correct query values', async () => { + const { container } = render( + + + , + ); + const startDateInput = container.querySelectorAll('input')[0]; + const endDateInput = container.querySelectorAll('input')[1]; + expect(startDateInput.placeholder).toBe('Click to select a start date'); + expect(startDateInput.value).toBe('2020-04-05'); + expect(endDateInput.placeholder).toBe('Click to select an end date'); + expect(endDateInput.value).toBe('2021-01-01'); + }); +}); diff --git a/frontend/src/components/teamsAndOrgs/tests/tasksStatsSummary.test.js b/frontend/src/components/teamsAndOrgs/tests/tasksStatsSummary.test.js new file mode 100644 index 0000000000..6c4ab86253 --- /dev/null +++ b/frontend/src/components/teamsAndOrgs/tests/tasksStatsSummary.test.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { ReduxIntlProviders } from '../../../utils/testWithIntl'; +import { tasksStats } from '../../../network/tests/mockData/tasksStats'; +import { TasksStatsSummary } from '../tasksStatsSummary'; + +test('TasksStatsSummary renders the correct values and labels', () => { + render( + + + , + ); + expect(screen.getByText('165')).toBeInTheDocument(); + expect(screen.getByText('Tasks mapped')).toBeInTheDocument(); + expect(screen.getByText('46')).toBeInTheDocument(); + expect(screen.getByText('Tasks validated')).toBeInTheDocument(); + expect(screen.getByText('211')).toBeInTheDocument(); + expect(screen.getByText('Completed actions')).toBeInTheDocument(); +}); diff --git a/frontend/src/components/userDetail/tests/editByNumbers.test.js b/frontend/src/components/userDetail/tests/editByNumbers.test.js index 3b8f758111..a8150ae4dd 100644 --- a/frontend/src/components/userDetail/tests/editByNumbers.test.js +++ b/frontend/src/components/userDetail/tests/editByNumbers.test.js @@ -5,6 +5,10 @@ import '@testing-library/jest-dom'; import { ReduxIntlProviders } from '../../../utils/testWithIntl'; import { EditsByNumbers } from '../editsByNumbers'; +jest.mock('react-chartjs-2', () => ({ + Doughnut: () => null, +})); + describe('EditsByNumbers card', () => { it('renders a message if the user has not stats yet', () => { render( @@ -45,6 +49,5 @@ describe('EditsByNumbers card', () => { 'No data to show yet. OpenStreetMap edits stats are updated with a delay of one hour.', ), ).not.toBeInTheDocument(); - expect(container.querySelector('canvas')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/userDetail/tests/topCauses.test.js b/frontend/src/components/userDetail/tests/topCauses.test.js index 23c3a667d5..8fbfae5774 100644 --- a/frontend/src/components/userDetail/tests/topCauses.test.js +++ b/frontend/src/components/userDetail/tests/topCauses.test.js @@ -5,6 +5,10 @@ import '@testing-library/jest-dom'; import { ReduxIntlProviders } from '../../../utils/testWithIntl'; import { TopCauses } from '../topCauses'; +jest.mock('react-chartjs-2', () => ({ + Doughnut: () => null, +})); + describe('TopCauses card', () => { it('renders a message if the user has not projects mapped yet', () => { const stats = { @@ -46,6 +50,5 @@ describe('TopCauses card', () => { expect( screen.queryByText('Information is not available because no projects were mapped until now.'), ).not.toBeInTheDocument(); - expect(container.querySelector('canvas')).toBeInTheDocument(); }); }); diff --git a/frontend/src/hooks/UseProjectsQueryAPI.js b/frontend/src/hooks/UseProjectsQueryAPI.js index 6b3ea11834..fbca94f96d 100644 --- a/frontend/src/hooks/UseProjectsQueryAPI.js +++ b/frontend/src/hooks/UseProjectsQueryAPI.js @@ -190,7 +190,7 @@ export const useProjectsQueryAPI = ( // The request was made and the server responded with a status code // that falls out of the range of 2xx console.log( - 'Res failure', + 'Response failure', error.response.data, error.response.status, error.response.headers, @@ -202,7 +202,7 @@ export const useProjectsQueryAPI = ( // The request was made but no response was received // `error.request` is an instance of XMLHttpRequest in the browser and an instance of // http.ClientRequest in node.js - console.log('req failure', error.request, errorReqPayload); + console.log('Request failure', error.request, errorReqPayload); dispatch({ type: 'FETCH_FAILURE', payload: errorReqPayload }); } else if (!didCancel) { dispatch({ type: 'FETCH_FAILURE' }); diff --git a/frontend/src/hooks/UseTasksStatsQueryAPI.js b/frontend/src/hooks/UseTasksStatsQueryAPI.js new file mode 100644 index 0000000000..0182d3d81d --- /dev/null +++ b/frontend/src/hooks/UseTasksStatsQueryAPI.js @@ -0,0 +1,191 @@ +import { useEffect, useReducer } from 'react'; +import { useSelector } from 'react-redux'; +import { useQueryParams, encodeQueryParams, StringParam, NumberParam } from 'use-query-params'; +import { stringify as stringifyUQP } from 'query-string'; +import axios from 'axios'; + +import { CommaArrayParam } from '../utils/CommaArrayParam'; +import { useThrottle } from '../hooks/UseThrottle'; +import { remapParamsToAPI } from '../utils/remapParamsToAPI'; +import { API_URL } from '../config'; + +/* See also moreFiltersForm, the useQueryParams are duplicated there for specific modular usage */ +/* This one is e.g. used for updating the URL when returning to /contribute + * and directly submitting the query to the API */ +const statsQueryAllSpecification = { + startDate: StringParam, + endDate: StringParam, + campaign: StringParam, + location: StringParam, + project: CommaArrayParam, + organisationName: StringParam, + organisationId: NumberParam, +}; + +export const useTasksStatsQueryParams = () => { + const uqp = useQueryParams(statsQueryAllSpecification); + return uqp; +}; + +/* The API uses slightly different JSON keys than the queryParams, + this fn takes an object with queryparam keys and outputs JSON keys + while maintaining the same values */ +/* TODO support full text search and change text=>project for that */ +const backendToQueryConversion = { + startDate: 'startDate', + endDate: 'endDate', + campaign: 'campaign', + location: 'country', + project: 'projectId', + organisationName: 'organisationName', + organisationId: 'organisationId', +}; + +const defaultInitialData = { + taskStats: [], +}; + +const dataFetchReducer = (state, action) => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isLoading: true, + isError: false, + }; + case 'FETCH_SUCCESS': + return { + ...state, + isLoading: false, + isError: false, + stats: action.payload.taskStats, + }; + case 'FETCH_FAILURE': + return { + ...state, + isLoading: false, + isError: true, + }; + default: + console.log(action); + throw new Error(); + } +}; + +export const useTasksStatsQueryAPI = ( + initialData = defaultInitialData, + ExternalQueryParamsState, + forceUpdate = null, + extraQuery = '', +) => { + const throttledExternalQueryParamsState = useThrottle(ExternalQueryParamsState, 1500); + const token = useSelector((state) => state.auth.get('token')); + + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: true, + isError: false, + stats: initialData.taskStats, + queryParamsState: ExternalQueryParamsState[0], + }); + + useEffect(() => { + let didCancel = false; + let cancel; + const fetchData = async () => { + const CancelToken = axios.CancelToken; + + dispatch({ + type: 'FETCH_INIT', + }); + + let headers = { + 'Content-Type': 'application/json', + Authorization: `Token ${token}`, + }; + const paramsRemapped = remapParamsToAPI( + throttledExternalQueryParamsState, + backendToQueryConversion, + ); + extraQuery.split(',').forEach((query) => { + const [key, value] = query.trim().split('='); + paramsRemapped[key] = value; + }); + + try { + const result = await axios({ + url: `${API_URL}tasks/statistics/`, + method: 'GET', + headers: headers, + params: paramsRemapped, + cancelToken: new CancelToken(function executor(c) { + // An executor function receives a cancel function as a parameter + cancel = { end: c, params: throttledExternalQueryParamsState }; + }), + }); + + if (!didCancel) { + if (result && result.headers && result.headers['content-type'].indexOf('json') !== -1) { + dispatch({ type: 'FETCH_SUCCESS', payload: result.data }); + } else { + console.error('Invalid return type for project search'); + dispatch({ type: 'FETCH_FAILURE' }); + } + } else { + cancel && cancel.end(); + } + } catch (error) { + /* if cancelled, this setting state of unmounted + * component with dispatch would be a memory leak */ + if ( + !didCancel && + error && + error.response && + error.response.data && + error.response.data.Error === 'No statistics found' + ) { + const zeroPayload = Object.assign(defaultInitialData, { pagination: { total: 0 } }); + /* TODO(tdk): when 404 and page > 1, re-request page 1 */ + dispatch({ type: 'FETCH_SUCCESS', payload: zeroPayload }); + } else if (!didCancel && error.response) { + const errorResPayload = Object.assign(defaultInitialData, { error: error.response }); + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + console.log( + 'Response failure', + error.response.data, + error.response.status, + error.response.headers, + errorResPayload, + ); + dispatch({ type: 'FETCH_FAILURE', payload: errorResPayload }); + } else if (!didCancel && error.request) { + const errorReqPayload = Object.assign(defaultInitialData, { error: error.request }); + // The request was made but no response was received + // `error.request` is an instance of XMLHttpRequest in the browser and an instance of + // http.ClientRequest in node.js + console.log('request failure', error.request, errorReqPayload); + dispatch({ type: 'FETCH_FAILURE', payload: errorReqPayload }); + } else if (!didCancel) { + dispatch({ type: 'FETCH_FAILURE' }); + } else { + console.log('tried to cancel on failure', cancel && cancel.params); + cancel && cancel.end(); + } + } + }; + + fetchData(); + return () => { + didCancel = true; + console.log('tried to cancel on effect cleanup ', cancel && cancel.params); + cancel && cancel.end(); + }; + }, [throttledExternalQueryParamsState, forceUpdate, token, extraQuery]); + + return [state, dispatch]; +}; + +export const stringify = (obj) => { + const encodedQuery = encodeQueryParams(statsQueryAllSpecification, obj); + return stringifyUQP(encodedQuery); +}; diff --git a/frontend/src/hooks/UseTotalTasksStats.js b/frontend/src/hooks/UseTotalTasksStats.js new file mode 100644 index 0000000000..5cd45778a5 --- /dev/null +++ b/frontend/src/hooks/UseTotalTasksStats.js @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; + +function getStatusCount(stats, status) { + return stats.reduce((total, entry) => total + entry[status], 0); +} + +export function useTotalTasksStats(stats) { + const [totalStats, setTotalStats] = useState({ + mapped: 0, + validated: 0, + }); + useEffect(() => { + if (stats && stats.length) { + const mapped = getStatusCount(stats, 'mapped'); + const validated = getStatusCount(stats, 'validated'); + setTotalStats({ + mapped: mapped, + validated: validated, + }); + } + }, [stats]); + return totalStats; +} diff --git a/frontend/src/hooks/tests/UseTotalTasksStats.test.js b/frontend/src/hooks/tests/UseTotalTasksStats.test.js new file mode 100644 index 0000000000..dc7d7acf30 --- /dev/null +++ b/frontend/src/hooks/tests/UseTotalTasksStats.test.js @@ -0,0 +1,22 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { useTotalTasksStats } from '../UseTotalTasksStats'; +import { tasksStats } from '../../network/tests/mockData/tasksStats'; + +describe('computeCompleteness', () => { + it('returns right numbers when receiving valid stats array', () => { + const { result } = renderHook(() => useTotalTasksStats(tasksStats.taskStats)); + expect(result.current.mapped).toBe(165); + expect(result.current.validated).toBe(46); + }); + it('returns 0 to all values if stats is not provided', () => { + const { result } = renderHook(() => useTotalTasksStats()); + expect(result.current.mapped).toBe(0); + expect(result.current.validated).toBe(0); + }); + it('returns 0 to all values if stats is an empty array', () => { + const { result } = renderHook(() => useTotalTasksStats([])); + expect(result.current.mapped).toBe(0); + expect(result.current.validated).toBe(0); + }); +}); diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 23d14fe826..7cd41e8cc7 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -444,6 +444,10 @@ "project.nav.mappingDifficulty": "Difficulty level", "project.nav.moreFilters": "More filters", "project.nav.campaign": "Campaign", + "navFilters.startDate": "From", + "navFilters.startDate.placeholder": "Click to select a start date", + "navFilters.endDate": "To", + "navFilters.endDatePlace.placeholder": "Click to select an end date", "project.nav.showMapToggle": "Show map", "project.nav.listViewToggle": "List view", "project.navFilters.typesOfMapping": "Types of mapping", @@ -696,6 +700,17 @@ "management.links.viewAll": "View all", "management.organisation": "Organization", "management.organisations": "Organizations", + "management.organisations.stats.to_be_mapped": "Tasks to be mapped", + "management.organisations.stats.tasks_mapped": "Tasks mapped", + "management.organisations.stats.ready_for_validation": "Ready for validation", + "management.organisations.stats.tasks_validated": "Tasks validated", + "management.organisations.stats.actions_needed": "Actions needed", + "management.organisations.stats.completed_actions": "Completed actions", + "management.organisations.stats.actions_needed.help": "Action means a mapping or validation operation. As each task needs to be mapped and validated, this is the number of actions needed to finish all the published projects of that organization.", + "management.organisations.stats.level.tooltip": "{n} of {total} ({percent}%) completed to move to level {nextLevel}", + "management.organisations.stats.level.description": "{org} is an organization level {level}.", + "management.organisations.stats.level.next": "After completing more {n} actions, it will reach the level {nextLevel}.", + "management.organisations.stats.level.top": "It is the highest level an organization can be on Tasking Manager!", "management.titles.organisation_information": "Organization information", "management.titles.team_information": "Team information", "management.titles.campaign_information": "Campaign information", @@ -861,6 +876,9 @@ "teamsAndOrgs.management.button.leave_team": "Leave team", "teamsAndOrgs.management.button.cancel": "Cancel", "teamsAndOrgs.management.organisation.manage.error": "You are not a manager of this organization, so you are not allowed to edit it.", + "teamsAndOrgs.management.organisation.stats": "Tasks statistics", + "teamsAndOrgs.management.organisation.remaining_tasks": "Total remaining", + "teamsAndOrgs.management.organisation.usage_level": "Level", "teamsAndOrgs.management.organisation.manage": "Manage organization", "teamsAndOrgs.management.team.manage": "Manage team", "teamsAndOrgs.management.campaign.manage": "Manage campaign", diff --git a/frontend/src/network/tests/mockData/tasksStats.js b/frontend/src/network/tests/mockData/tasksStats.js new file mode 100644 index 0000000000..5354904a0d --- /dev/null +++ b/frontend/src/network/tests/mockData/tasksStats.js @@ -0,0 +1,67 @@ +export const tasksStats = { + taskStats: [ + { + date: '2021-01-01', + mapped: 10, + validated: 3, + invalidated: 1, + badimagery: 0, + }, + { + date: '2021-01-05', + mapped: 11, + validated: 2, + invalidated: 1, + badimagery: 2, + }, + { + date: '2021-01-08', + mapped: 11, + validated: 2, + invalidated: 0, + badimagery: 0, + }, + { + date: '2021-01-10', + mapped: 20, + validated: 7, + invalidated: 10, + badimagery: 4, + }, + { + date: '2021-01-12', + mapped: 20, + validated: 5, + invalidated: 0, + badimagery: 0, + }, + { + date: '2021-01-15', + mapped: 25, + validated: 2, + invalidated: 1, + badimagery: 0, + }, + { + date: '2021-01-18', + mapped: 21, + validated: 0, + invalidated: 2, + badimagery: 0, + }, + { + date: '2021-01-19', + mapped: 34, + validated: 10, + invalidated: 3, + badimagery: 2, + }, + { + date: '2021-01-22', + mapped: 13, + validated: 15, + invalidated: 6, + badimagery: 0, + }, + ], +}; diff --git a/frontend/src/utils/formatChartJSData.js b/frontend/src/utils/formatChartJSData.js index 12f024743b..634d21ec78 100644 --- a/frontend/src/utils/formatChartJSData.js +++ b/frontend/src/utils/formatChartJSData.js @@ -38,6 +38,25 @@ export const formatTimelineData = (stats, mappedColour, validatedColour) => { return { datasets: [validated, mapped], labels: labels }; }; +export const formatTasksStatsData = (stats, mappedColour, validatedColour) => { + let mapped = { + data: [], + backgroundColor: mappedColour, + label: 'Mapped tasks', + }; + let validated = { + data: [], + backgroundColor: validatedColour, + label: 'Validated tasks', + }; + + const labels = stats.map((entry) => entry.date); + mapped.data = stats.map((entry) => entry.mapped); + validated.data = stats.map((entry) => entry.validated); + + return { datasets: [mapped, validated], labels: labels }; +}; + export const formatTooltip = (tooltipItem, data) => { var label = data.labels[tooltipItem.index] || ''; if (label) label += ': '; @@ -46,10 +65,10 @@ export const formatTooltip = (tooltipItem, data) => { return (label += '%'); }; -export const formatTimelineTooltip = (tooltipItem, data) => { +export const formatTimelineTooltip = (tooltipItem, data, isPercent) => { var label = data.datasets[tooltipItem.datasetIndex].label || ''; if (label) label += ': '; label += data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index]; - return (label += '%'); + return `${label}${isPercent ? '%' : ''}`; }; diff --git a/frontend/src/utils/tests/formatChartJSData.test.js b/frontend/src/utils/tests/formatChartJSData.test.js index bb9ef10bed..95ca4e11eb 100644 --- a/frontend/src/utils/tests/formatChartJSData.test.js +++ b/frontend/src/utils/tests/formatChartJSData.test.js @@ -104,7 +104,8 @@ describe('formatTimelineTooltip', () => { labels: ['2020-05-19', '2020-06-01', '2020-06-26'], }; it('returns correct information for Mapped tasks', () => { - expect(formatTimelineTooltip(tooltipItem, data)).toBe('Mapped tasks: 31%'); + expect(formatTimelineTooltip(tooltipItem, data, true)).toBe('Mapped tasks: 31%'); + expect(formatTimelineTooltip(tooltipItem, data)).toBe('Mapped tasks: 31'); }); it('returns correct information for Validated tasks', () => { const tooltipItem2 = { @@ -117,7 +118,8 @@ describe('formatTimelineTooltip', () => { x: 1074.8309643713924, y: 78.45394354462593, }; - expect(formatTimelineTooltip(tooltipItem2, data)).toBe('Validated tasks: 0%'); + expect(formatTimelineTooltip(tooltipItem2, data, true)).toBe('Validated tasks: 0%'); + expect(formatTimelineTooltip(tooltipItem2, data)).toBe('Validated tasks: 0'); }); }); diff --git a/frontend/src/views/messages.js b/frontend/src/views/messages.js index fa5dd4cdec..8c26fa51a8 100644 --- a/frontend/src/views/messages.js +++ b/frontend/src/views/messages.js @@ -101,9 +101,9 @@ export default defineMessages({ defaultMessage: 'You are not a manager of this organization, so you are not allowed to edit it.', }, - statistics: { + tasksStatistics: { id: 'teamsAndOrgs.management.organisation.stats', - defaultMessage: 'Statistics', + defaultMessage: 'Tasks statistics', }, remainingTasks: { id: 'teamsAndOrgs.management.organisation.remaining_tasks', diff --git a/frontend/src/views/organisationStats.js b/frontend/src/views/organisationStats.js new file mode 100644 index 0000000000..d518f7f7b5 --- /dev/null +++ b/frontend/src/views/organisationStats.js @@ -0,0 +1,84 @@ +import React, { useEffect } from 'react'; +import { startOfYear, format } from 'date-fns'; +import ReactPlaceholder from 'react-placeholder'; +import { FormattedMessage } from 'react-intl'; + +import messages from './messages'; +import { useTasksStatsQueryParams, useTasksStatsQueryAPI } from '../hooks/UseTasksStatsQueryAPI'; +import { useForceUpdate } from '../hooks/UseForceUpdate'; +import { useTotalTasksStats } from '../hooks/UseTotalTasksStats'; +import { useFetch } from '../hooks/UseFetch'; +import { useSetTitleTag } from '../hooks/UseMetaTags'; +import { RemainingTasksStats } from '../components/teamsAndOrgs/remainingTasksStats'; +import { OrganisationUsageLevel } from '../components/teamsAndOrgs/orgUsageLevel'; +const TasksStats = React.lazy(() => import('../components/teamsAndOrgs/tasksStats')); + +export const OrganisationStats = ({ id }) => { + const [query, setQuery] = useTasksStatsQueryParams(); + // eslint-disable-next-line + const [forceUpdated, forceUpdate] = useForceUpdate(); + useEffect(() => { + if (!query.startDate) { + setQuery({ ...query, startDate: format(startOfYear(Date.now()), 'yyyy-MM-dd') }); + } + }); + const [stats] = useTasksStatsQueryAPI( + { taskStats: [] }, + query, + query.startDate ? forceUpdated : false, + `organisationId=${id}`, + ); + const [error, loading, organisation] = useFetch(`organisations/${id}/?omitManagerList=true`, id); + const [errorOrgStats, loadingOrgStats, orgStats] = useFetch( + `organisations/${id}/statistics/`, + id, + ); + const totalStats = useTotalTasksStats(stats.stats); + useSetTitleTag(`${organisation.name || 'Organization'} stats`); + + return ( + +
+ {organisation.name} +

{organisation.name}

+
+

+ +

+ Loading...
}> + + +
+
+

+ +

+ + + +
+
+

+ +

+ + + +
+ +
+ ); +}; diff --git a/frontend/src/views/organisations.js b/frontend/src/views/organisations.js index a1c8a32c41..acee29f643 100644 --- a/frontend/src/views/organisations.js +++ b/frontend/src/views/organisations.js @@ -13,8 +13,6 @@ import { pushToLocalJSONAPI, fetchLocalJSONAPI } from '../network/genericJSONReq import { Members } from '../components/teamsAndOrgs/members'; import { Teams } from '../components/teamsAndOrgs/teams'; import { Projects } from '../components/teamsAndOrgs/projects'; -import { RemainingTasksStats } from '../components/teamsAndOrgs/remainingTasksStats'; -import { OrganisationUsageLevel } from '../components/teamsAndOrgs/orgUsageLevel'; import { OrganisationForm, CreateOrgInfo, @@ -252,55 +250,3 @@ export function EditOrganisation(props) { ); } - -export const OrganisationStats = ({ id }) => { - const [error, loading, organisation] = useFetch(`organisations/${id}/?omitManagerList=true`, id); - const [errorOrgStats, loadingOrgStats, orgStats] = useFetch( - `organisations/${id}/statistics/`, - id, - ); - useSetTitleTag(`${organisation.name || 'Organization'} stats`); - - return ( - -
- {organisation.name} -

{organisation.name}

-
-

- -

- Loading...
}> - { - // space for a chart - } - -
-
-

- -

- - - -
-
-

- -

- -
- -
- ); -}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 89eb34d1c5..18db97967c 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -5308,6 +5308,11 @@ date-fns@^2.0.1: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.14.0.tgz#359a87a265bb34ef2e38f93ecf63ac453f9bc7ba" integrity sha512-1zD+68jhFgDIM0rF05rcwYO8cExdNqxjq4xP1QKM60Q45mnO6zaMWB4tOzrIr4M4GSLntsKeE4c9Bdl2jhL/yw== +date-fns@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.16.1.tgz#05775792c3f3331da812af253e1a935851d3834b" + integrity sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"